Skip to content

Comments

Add comprehensive functional tests with mocked AI for CI readiness#6

Open
AbirAbbas wants to merge 1 commit intomainfrom
feature/8e313d79-functional-tests-no-ci
Open

Add comprehensive functional tests with mocked AI for CI readiness#6
AbirAbbas wants to merge 1 commit intomainfrom
feature/8e313d79-functional-tests-no-ci

Conversation

@AbirAbbas
Copy link
Collaborator

@AbirAbbas AbirAbbas commented Feb 18, 2026

Summary

  • Add 4 new functional test modules (planner pipeline, execute pipeline, malformed responses, NODE_ID isolation) covering the full orchestration flow end-to-end with mocked AI
  • Mock all LLM/AgentAI calls at the swe_af.app.app.call boundary — zero real API calls, CI-safe
  • Fix 5 existing tests/fast/ test files to match current interfaces (router wiring, verifier, cross-feature, integration)
  • Add pytest-asyncio>=0.23 to dev deps with asyncio_mode='auto' in pyproject.toml

Changes

File Change
pyproject.toml Add pytest-asyncio to [dev] optional-deps; add [tool.pytest.ini_options] with asyncio_mode = "auto"
tests/conftest.py New — root-level mock_agent_ai (function-scoped) and agentfield_server_guard (session-scoped autouse) fixtures
tests/test_planner_pipeline.py New — tests for plan() reasoner returning realistic Pydantic-schema responses
tests/test_planner_execute.py New — tests for execute() reasoner with single-issue and external-action paths
tests/test_malformed_responses.py New — error-path tests: missing fields (parsed=None), exception in run(), malformed envelope KeyError, success envelope with no inner result
tests/test_node_id_isolation.py New — 16 subprocess-based tests confirming NODE_ID isolation between swe-planner and swe-fast modules
swe_af/fast/verifier.py Minor fix to match current interface expected by tests
tests/fast/test_*.py (5 files) Fix keyword-only marker, ai_provider value, assertion updates, and path portability

Note on CI workflow: The intended CI change (adding a pytest step to .github/workflows/ci.yml) could not be included in this branch because the available PAT lacks workflow scope. The pytest step to add after make check is:

- name: Run pytest
  run: python -m pytest tests/ -x -q
  env:
    AGENTFIELD_SERVER: http://localhost:9999
    NODE_ID: test-node

This can be added directly to main by a maintainer with the appropriate token scope.

Test plan

  • python -m pytest tests/ -x -q with AGENTFIELD_SERVER=http://localhost:9999 NODE_ID=test-node — 434 pass, 1 skip, 0 real API calls
  • python -m pytest tests/fast/ -x -q — 386 pass, 1 skip (fast/ suite)
  • python -m unittest discover — 22 tests OK (existing unittest suite unaffected)
  • make check — install + lint pass with no regressions
  • Confirm no real API hostnames in test code: grep -r 'agentfield\.io\|openai\.com\|anthropic\.com' tests/ → empty

🤖 Built with AgentField SWE-AF
🔌 Powered by AgentField


📋 PRD (Product Requirements Document)

PRD: Multi-Repository Workspace Support

Date: 2026-02-18
Author: Product Manager (AI Agent)
Status: Final — Ready for Architecture & Engineering Execution


1. Problem Statement

SWE-AF's build() function accepts exactly one repository via repo_url: str (and companion repo_path: str). All downstream stages — workspace setup, git operations, worktree management, prompt context, PR creation — are hard-coded to operate on a single repo_path. Teams shipping multi-service architectures (e.g., an API repo plus a shared-types library, a monorepo with per-service sub-projects, or a backend service alongside a dependent frontend) cannot use SWE-AF without pre-merging code or decomposing tasks manually.

This PRD specifies the changes required to support N ≥ 1 repositories per build, while maintaining full backward compatibility for all existing single-repo callers.


2. Goal

Enable SWE-AF to accept a list of repositories with role metadata (primary vs. dependency), clone them efficiently in parallel, lay them out in predictable workspace directories, and propagate multi-repo awareness through every stage: planning prompts, coding prompts, git operations (commit/diff/PR per repo), and final PR output.


3. Success Metrics

  1. build() accepts repos: list[RepoSpec] and produces correct BuildResult when N > 1.
  2. Parallel clone time for N repos ≤ sequential time for the slowest single repo (clones run concurrently).
  3. Planner, coder, and verifier prompts contain per-repo path annotations when N > 1.
  4. Git operations (commit, diff, branch, PR) are scoped to the correct repo root per issue.
  5. Each repo with create_pr=True receives an independent draft PR.
  6. All existing tests pass without modification when repos is omitted or has a single entry.

4. Scope

4.1 Must-Have

  1. RepoSpec data model — New Pydantic model capturing per-repo metadata.
  2. build() parameter extension — Accept repos: list[RepoSpec] | None alongside legacy repo_url/repo_path.
  3. Backward-compatibility normalization — Coerce legacy inputs to list[RepoSpec] at entry; single-repo callers see no behavior change.
  4. Parallel workspace cloning — Clone all repos concurrently using asyncio.gather; uses asyncio.to_thread wrapping subprocess calls.
  5. Deterministic directory layout — Each repo mounts at a configurable or derived path within a per-build workspace root.
  6. WorkspaceManifest model — Runtime record of all mounted repos with absolute paths.
  7. Multi-repo BuildConfig & ExecutionConfig — Replace single repo_url: str field with repos: list[RepoSpec]; expose a primary_repo property returning the designated primary.
  8. Planner prompt injection — When N > 1, inject workspace manifest into PM, architect, and sprint-planner system/task prompts so they reason about correct file paths.
  9. Coder prompt injection — Inject workspace manifest and issue's target_repo into coder task prompt; coder must commit to correct worktree root.
  10. Verifier prompt injection — Verifier receives full workspace manifest; acceptance-criterion mapping includes repo scope.
  11. Per-repo git initrun_git_init is called once per repo; GitInitResult extended to carry repo_name.
  12. Per-repo worktree scoping — Worktree paths and branch names are prefixed with repo slug to avoid collisions.
  13. Per-repo git operations in coding loopCoderResult.repo_name field identifies which repo was modified; merger targets correct repo root.
  14. Per-repo PR generationrun_github_pr invoked once per repo that has create_pr=True; cross-repo PR cross-references added to each body.
  15. Per-repo GitHubPRResult in BuildResultpr_url field deprecated in favor of pr_results: list[RepoPRResult]; pr_url property maintained as backward-compat alias.

4.2 Nice-to-Have

  1. Sparse-checkout supportRepoSpec.sparse_paths: list[str] enables shallow file-tree clones for large monorepos.
  2. Shared git object cache — Persist a bare mirror in ~/.swe-af/git-cache/<host>/<org>/<repo>.git across builds for faster re-clones.
  3. Per-repo BuildConfig overrides — Override model, runtime, or PR settings on a per-repo basis.
  4. Cross-repo PR body cross-references — Auto-insert "Related PRs: repo-B#N" in each PR body when all_pr_results is provided.
  5. WorkspaceManifest serialized to .artifacts/plan/workspace.json — Human-readable record of what was cloned and where.

4.3 Out-of-Scope

  1. Monorepo sub-path issue routing — Sprint planner already uses files_to_create/files_to_modify; no dedicated monorepo sub-path DAG logic required.
  2. Automatic dependency detection — SWE-AF will not inspect package.json, Cargo.toml, pyproject.toml etc. to auto-discover repos; callers supply the repo list explicitly.
  3. Cross-repo merge orchestration — SWE-AF does not coordinate atomic cross-repo releases. Each repo is independently versioned.
  4. Git submodule management — Repos with existing submodule relationships are NOT recursively cloned; submodule resolution is caller's responsibility.
  5. Credential per-repo differentiation — All repos use the same git credentials present in the environment.
  6. UI/CLI changes — The main() CLI entry point remains unchanged; repos parameter is accessible only via programmatic build() calls and config dict.

5. Data Model Specification

5.1 RepoSpec (new — swe_af/execution/schemas.py)

class RepoSpec(BaseModel):
    repo_url: str                          # HTTPS or SSH git URL
    repo_path: str = ""                    # Absolute path; auto-derived if empty
    role: Literal["primary", "dependency"] = "primary"
    branch: str = ""                       # Checkout this branch; empty = repo default
    sparse_paths: list[str] = []           # If non-empty, enable sparse-checkout
    mount_point: str = ""                  # Subdirectory name in workspace root; auto-derived from repo name if empty
    create_pr: bool = True                 # Whether to create a GitHub PR for this repo

Constraints (enforced in model_validator):

  • Exactly one RepoSpec with role="primary" in any list[RepoSpec].
  • repo_url must match r"^(https?://|git@|ssh://)[^\s]+".
  • mount_point must not contain .. or absolute path separators.

5.2 WorkspaceRepo (new — swe_af/execution/schemas.py)

class WorkspaceRepo(BaseModel):
    repo_name: str             # Derived from URL (last path segment, no .git)
    repo_url: str
    role: Literal["primary", "dependency"]
    absolute_path: str         # Where repo is on disk
    branch: str                # Checked-out branch after clone
    sparse_paths: list[str]
    create_pr: bool
    git_init_result: dict | None = None   # Populated after git_init per repo

5.3 WorkspaceManifest (new — swe_af/execution/schemas.py)

class WorkspaceManifest(BaseModel):
    workspace_root: str           # Absolute path to per-build workspace dir
    repos: list[WorkspaceRepo]    # One entry per cloned repo
    primary_repo_name: str        # repo_name of the designated primary

5.4 RepoPRResult (new — swe_af/execution/schemas.py)

class RepoPRResult(BaseModel):
    repo_name: str
    repo_url: str
    success: bool
    pr_url: str = ""
    pr_number: int = 0
    error_message: str = ""

5.5 BuildConfig changes (existing — swe_af/execution/schemas.py)

New field:

repos: list[RepoSpec] = []   # Empty = use legacy repo_url / repo_path

New property:

@property
def primary_repo(self) -> RepoSpec | None:
    """Returns the RepoSpec with role='primary', or None if repos is empty."""

New model_validator(mode="after"):

  • If repos is empty AND repo_url is non-empty: synthesize repos = [RepoSpec(repo_url=repo_url, role="primary")].
  • If repos is non-empty: set repo_url = primary_repo.repo_url for legacy code paths.
  • Raise ValueError if repos is non-empty AND repo_url is non-empty simultaneously (ambiguous input).
  • Raise ValueError if zero entries have role="primary" after normalization.
  • Raise ValueError if more than one entry has role="primary".
  • Raise ValueError if duplicate mount_point or duplicate derived repo names exist.

5.6 BuildResult changes (existing — swe_af/execution/schemas.py)

# NEW
pr_results: list[RepoPRResult] = []

# DEPRECATED alias (kept for backward compat)
@property
def pr_url(self) -> str:
    """Returns first successful PR URL or empty string. Deprecated; use pr_results."""
    for r in self.pr_results:
        if r.success:
            return r.pr_url
    return ""

NOTE: The existing pr_url: str = "" field must be REMOVED and replaced by the property above plus pr_results list.

5.7 DAGState changes (existing — swe_af/execution/schemas.py)

# NEW
workspace_manifest: dict | None = None   # Serialized WorkspaceManifest

5.8 PlannedIssue changes (existing — swe_af/reasoners/schemas.py)

# NEW optional field
target_repo: str = ""   # repo_name this issue's changes land in; empty = primary

5.9 CoderResult changes (existing — swe_af/execution/schemas.py)

# NEW optional field
repo_name: str = ""     # Which repo was modified; empty = primary

5.10 GitInitResult changes (existing — swe_af/execution/schemas.py)

# NEW optional field
repo_name: str = ""     # Populated from run_git_init's repo_name parameter

5.11 MergeResult changes (existing — swe_af/execution/schemas.py)

# NEW optional field
repo_name: str = ""     # Which repo this merge result covers

6. Workspace Setup

6.1 New Function: _clone_repos

File: swe_af/app.py

async def _clone_repos(
    cfg: BuildConfig,
    artifacts_dir: str,
) -> WorkspaceManifest:
    """
    Clone all repos in cfg.repos concurrently.
    Raises RuntimeError if any clone fails.
    Returns WorkspaceManifest with absolute_path populated for each repo.
    """

Implementation requirements:

  • Use asyncio.gather(*[asyncio.to_thread(_clone_single, spec, workspace_root) for spec in cfg.repos], return_exceptions=True).
  • If any result is an exception, clean up all partially-cloned directories and re-raise as RuntimeError.
  • For single-repo builds: workspace_root is the parent of spec.repo_path (no structural change from today).
  • For multi-repo builds (N > 1): workspace_root = os.path.join(artifacts_dir, "workspace").
  • target_path per repo: spec.repo_path if non-empty, else os.path.join(workspace_root, mount_point_or_derived_name).

6.2 Re-clone Handling

Extract existing re-clone logic into _clean_existing_repo(repo_path: str, remote_url: str) -> None and call once per repo within _clone_single.

6.3 Directory Layout

Single-repo builds: identical to current behavior — repo_path is used as-is.

Multi-repo builds (N > 1):

{artifacts_dir}/workspace/
  {repo_name_A}/     ← primary repo (or mount_point if specified)
  {repo_name_B}/     ← dependency repo

7. Prompt System Updates

7.1 New Utility: workspace_context_block

File: swe_af/prompts/_utils.py (new file)

def workspace_context_block(manifest: WorkspaceManifest) -> str:
    """
    Returns empty string if manifest.repos has exactly one entry.
    Returns markdown workspace layout section for multi-repo manifests.
    """

Output format (multi-repo):

## Workspace Layout
Primary repository: {primary_repo_name} at {primary_path}

| Repo | Role | Path | Branch |
|------|------|------|--------|
| {name} | primary | {path} | {branch} |
| {name} | dependency | {path} | {branch} |

All file references in this build MUST use absolute paths or paths relative to the
repo root listed above. Cross-repo imports must respect the actual on-disk layout.

7.2 PM Prompt (swe_af/prompts/product_manager.py)

pm_task_prompt() gains workspace_manifest: WorkspaceManifest | None = None (keyword-only).
When non-None and len > 1, append workspace_context_block(manifest) after repository context.
Add directive: "When writing acceptance criteria that reference file paths, always prefix with the repository name (e.g., repo-name/path/to/file)."

7.3 Architect Prompt (swe_af/prompts/architect.py)

architect_task_prompt() gains workspace_manifest: WorkspaceManifest | None = None (keyword-only).
When multi-repo, append workspace_context_block(manifest) and add:
"Cross-repo components must reference files using absolute paths or repo-relative paths with repo name prefix. Define interface contracts that include the target_repo field for each component."

7.4 Sprint Planner Prompt (swe_af/prompts/sprint_planner.py)

sprint_planner_task_prompt() gains workspace_manifest: WorkspaceManifest | None = None (keyword-only).
When multi-repo:

  • Append workspace_context_block(manifest).
  • Add: "Each PlannedIssue MUST include target_repo set to the repo_name where changes land."
  • Add: "Cross-repo issues are NOT allowed. If a feature requires changes in repo-A and repo-B, create two issues with depends_on relationships."

7.5 Coder Prompt (swe_af/prompts/coder.py)

coder_task_prompt() gains workspace_manifest: WorkspaceManifest | None = None and target_repo: str = "" (both keyword-only).
When multi-repo, prepend to task context:

## Repository Scope
You are editing repository: {target_repo}
Repository root: {absolute_path_of_target_repo}

{workspace_context_block(manifest)}

CRITICAL: All git operations (git add, git commit) must be run from {absolute_path_of_target_repo}.
Do NOT commit files from other repositories.

When single-repo: no change.

7.6 Verifier Prompt (swe_af/prompts/verifier.py)

verifier_task_prompt() gains workspace_manifest: WorkspaceManifest | None = None (keyword-only).
When multi-repo, append workspace_context_block(manifest) and add:
"Acceptance criteria referencing files in dependency repos must be verified at the correct repo path."

7.7 GitHub PR Prompt (swe_af/prompts/github_pr.py)

github_pr_task_prompt() gains all_pr_results: list[RepoPRResult] | None = None (keyword-only).
When all_pr_results is non-empty and contains successful PRs for other repos, append "Related PRs" section to generated PR body.


8. DAG Execution Changes

8.1 Per-Repo Git Init

In app.py, after _clone_repos(), call run_git_init once per repo concurrently. Collect results into WorkspaceManifest.repos[i].git_init_result. Primary repo's result continues to populate DAGState.git_integration_branch etc.

8.2 Per-Repo Worktree Scoping

workspace_setup_task_prompt (and by extension run_workspace_setup) gains workspace_manifest: WorkspaceManifest | None = None.

Multi-repo worktree path pattern:

{repo_root}/.worktrees/{build_id}-{repo_name}-{issue_nn}-{issue_name}

Branch name pattern:

issue/{build_id}-{repo_name}-{issue_nn}-{issue_name}

Single-repo: existing patterns unchanged (no repo_name prefix).

8.3 Coding Loop — Repo-Scoped Git Operations

run_coding_loop passes workspace_manifest and issue.get("target_repo", "") into coder_task_prompt. If target_repo is empty, coder operates on primary repo (preserves backward compat).

8.4 Merger — Per-Repo Branch Merging

run_merger receives workspace_manifest. Groups pending_merge_branches by repo_name and merges each group into the respective repo's integration branch. Each merge produces a MergeResult with repo_name set.

8.5 Integration Tester

run_integration_tester receives workspace_manifest. Integration tests run from primary repo root by default; test commands may reference other repos via absolute paths.


9. PR/Output Phase

9.1 Per-Repo PR Creation

In app.py, after verification, iterate workspace_manifest.repos where repo.create_pr=True. Call run_github_pr once per repo. Collect RepoPRResult list. Pass the growing list as all_pr_results to each subsequent call (enables cross-references).

pr_results: list[RepoPRResult] = []
for repo in manifest.repos:
    if not repo.create_pr:
        continue
    result = await run_github_pr(
        repo_path=repo.absolute_path,
        integration_branch=repo.git_init_result["integration_branch"],
        base_branch=...,
        goal=goal,
        completed_issues=issues_for_repo(repo.repo_name),
        accumulated_debt=...,
        artifacts_dir=artifacts_dir,
        all_pr_results=pr_results,
    )
    pr_results.append(RepoPRResult(...))

9.2 Plan Docs in PR

PRD and architecture <details> sections are appended to the primary repo's PR body only.


10. Backward Compatibility Contract

Any existing call build(goal=..., repo_url=..., repo_path=...) with no repos argument produces IDENTICAL behavior to pre-feature behavior.

Specifically:

  • BuildResult.pr_url returns a string (not None, not a list)
  • DAGState fields unchanged in shape and meaning
  • No new directories created under repo_path for single-repo builds
  • All existing tests pass without modification

Mechanism: BuildConfig.model_validator synthesizes repos = [RepoSpec(repo_url=repo_url, ...)] when repos is empty and repo_url is non-empty. For single-entry repos lists, workspace root is the existing repo_path — not a new subdirectory.


11. Interface Contracts

11.1 _clone_repos (new, swe_af/app.py)

async def _clone_repos(cfg: BuildConfig, artifacts_dir: str) -> WorkspaceManifest

11.2 workspace_context_block (new, swe_af/prompts/_utils.py)

def workspace_context_block(manifest: WorkspaceManifest) -> str

11.3 workspace_setup_task_prompt (modified, swe_af/prompts/workspace.py)

def workspace_setup_task_prompt(
    ...,
    workspace_manifest: WorkspaceManifest | None = None,  # NEW keyword param
) -> str

11.4 github_pr_task_prompt (modified, swe_af/prompts/github_pr.py)

def github_pr_task_prompt(
    ...,
    all_pr_results: list[RepoPRResult] | None = None,  # NEW keyword param
) -> str

11.5 run_git_init runner (modified, swe_af/prompts/git_init.py and runner)

The git_init_task_prompt gains repo_name: str = "" as keyword param, which is injected into the agent context and returned in GitInitResult.repo_name.


12. Acceptance Criteria

All criteria are machine-verifiable. A QA agent validates each by running the specified command.

AC-01: RepoSpec model validation

python -c "
from swe_af.execution.schemas import RepoSpec
from pydantic import ValidationError
import sys

r = RepoSpec(repo_url='https://github.com/org/repo.git', role='primary')
assert r.role == 'primary'
assert r.create_pr == True
assert r.sparse_paths == []
assert r.branch == ''
assert r.mount_point == ''

try:
    RepoSpec(repo_url='not-a-url', role='primary')
    sys.exit(1)
except (ValidationError, ValueError):
    pass

print('AC-01 PASS')
"

AC-02: BuildConfig normalizes legacy repo_url to repos

python -c "
from swe_af.execution.schemas import BuildConfig
cfg = BuildConfig(repo_url='https://github.com/org/repo.git')
assert len(cfg.repos) == 1
assert cfg.repos[0].repo_url == 'https://github.com/org/repo.git'
assert cfg.repos[0].role == 'primary'
assert cfg.primary_repo is not None
assert cfg.primary_repo.repo_url == 'https://github.com/org/repo.git'
print('AC-02 PASS')
"

AC-03: BuildConfig rejects multiple primary repos

python -c "
from swe_af.execution.schemas import BuildConfig, RepoSpec
from pydantic import ValidationError
import sys
try:
    cfg = BuildConfig(repos=[
        RepoSpec(repo_url='https://github.com/org/a.git', role='primary'),
        RepoSpec(repo_url='https://github.com/org/b.git', role='primary'),
    ])
    sys.exit(1)
except (ValidationError, ValueError):
    pass
print('AC-03 PASS')
"

AC-04: BuildConfig rejects both repo_url and repos set simultaneously

python -c "
from swe_af.execution.schemas import BuildConfig, RepoSpec
from pydantic import ValidationError
import sys
try:
    cfg = BuildConfig(
        repo_url='https://github.com/org/a.git',
        repos=[RepoSpec(repo_url='https://github.com/org/b.git', role='primary')],
    )
    sys.exit(1)
except (ValidationError, ValueError):
    pass
print('AC-04 PASS')
"

AC-05: BuildConfig sets repo_url from primary in multi-repo mode

python -c "
from swe_af.execution.schemas import BuildConfig, RepoSpec
cfg = BuildConfig(repos=[
    RepoSpec(repo_url='https://github.com/org/api.git', role='primary'),
    RepoSpec(repo_url='https://github.com/org/lib.git', role='dependency'),
])
assert cfg.repo_url == 'https://github.com/org/api.git', f'Got: {cfg.repo_url}'
print('AC-05 PASS')
"

AC-06: WorkspaceManifest model construction and JSON serialization

python -c "
import json
from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo
m = WorkspaceManifest(
    workspace_root='/tmp/ws',
    repos=[WorkspaceRepo(
        repo_name='myrepo',
        repo_url='https://github.com/org/myrepo.git',
        role='primary',
        absolute_path='/tmp/ws/myrepo',
        branch='main',
        sparse_paths=[],
        create_pr=True,
    )],
    primary_repo_name='myrepo',
)
assert m.primary_repo_name == 'myrepo'
assert m.repos[0].absolute_path == '/tmp/ws/myrepo'
j = m.model_dump_json(indent=2)
parsed = json.loads(j)
assert parsed['primary_repo_name'] == 'myrepo'
print('AC-06 PASS')
"

AC-07: RepoPRResult model construction

python -c "
from swe_af.execution.schemas import RepoPRResult
r = RepoPRResult(repo_name='myrepo', repo_url='https://github.com/org/myrepo.git', success=True, pr_url='https://github.com/org/myrepo/pull/1', pr_number=1)
assert r.repo_name == 'myrepo'
assert r.success == True
assert r.pr_number == 1
print('AC-07 PASS')
"

AC-08: BuildResult.pr_url backward-compat property

python -c "
from swe_af.execution.schemas import BuildResult, RepoPRResult
br = BuildResult(
    plan_result={}, dag_state={}, verification=None, success=True, summary='',
    pr_results=[RepoPRResult(repo_name='r', repo_url='https://github.com/org/r.git', success=True, pr_url='https://github.com/org/r/pull/1', pr_number=1)]
)
assert br.pr_url == 'https://github.com/org/r/pull/1'

br2 = BuildResult(plan_result={}, dag_state={}, verification=None, success=True, summary='', pr_results=[])
assert br2.pr_url == ''
print('AC-08 PASS')
"

AC-09: DAGState has workspace_manifest field defaulting to None

python -c "
from swe_af.execution.schemas import DAGState
ds = DAGState(repo_path='/tmp/repo', artifacts_dir='/tmp/artifacts')
assert hasattr(ds, 'workspace_manifest')
assert ds.workspace_manifest is None
print('AC-09 PASS')
"

AC-10: PlannedIssue has target_repo field defaulting to empty string

python -c "
from swe_af.reasoners.schemas import PlannedIssue
pi = PlannedIssue(name='test-issue', title='Test', description='desc', acceptance_criteria=['AC1'], depends_on=[], provides=[], files_to_create=[], files_to_modify=[], testing_strategy='pytest', sequence_number=1)
assert hasattr(pi, 'target_repo')
assert pi.target_repo == ''
pi2 = PlannedIssue(name='test-issue', title='Test', description='desc', acceptance_criteria=['AC1'], depends_on=[], provides=[], files_to_create=[], files_to_modify=[], testing_strategy='pytest', sequence_number=1, target_repo='myrepo')
assert pi2.target_repo == 'myrepo'
print('AC-10 PASS')
"

AC-11: CoderResult has repo_name field defaulting to empty string

python -c "
from swe_af.execution.schemas import CoderResult
cr = CoderResult(files_changed=[], summary='done', complete=True, tests_passed=True, test_summary='all pass')
assert hasattr(cr, 'repo_name')
assert cr.repo_name == ''
print('AC-11 PASS')
"

AC-12: GitInitResult has repo_name field defaulting to empty string

python -c "
from swe_af.execution.schemas import GitInitResult
gir = GitInitResult(mode='fresh', integration_branch='main', original_branch='main', initial_commit_sha='abc123', success=True)
assert hasattr(gir, 'repo_name')
assert gir.repo_name == ''
print('AC-12 PASS')
"

AC-13: MergeResult has repo_name field defaulting to empty string

python -c "
from swe_af.execution.schemas import MergeResult
mr = MergeResult(success=True, merged_branches=[], failed_branches=[], conflict_resolutions=[], merge_commit_sha='abc', pre_merge_sha='def', needs_integration_test=False, integration_test_rationale='', summary='')
assert hasattr(mr, 'repo_name')
assert mr.repo_name == ''
print('AC-13 PASS')
"

AC-14: workspace_context_block returns empty string for single repo

python -c "
from swe_af.prompts._utils import workspace_context_block
from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo
m = WorkspaceManifest(
    workspace_root='/tmp',
    repos=[WorkspaceRepo(repo_name='a', repo_url='https://github.com/org/a.git', role='primary', absolute_path='/tmp/a', branch='main', sparse_paths=[], create_pr=True)],
    primary_repo_name='a',
)
result = workspace_context_block(m)
assert result == '', f'Expected empty string, got: {repr(result)}'
print('AC-14 PASS')
"

AC-15: workspace_context_block returns table with all repos for multi-repo

python -c "
from swe_af.prompts._utils import workspace_context_block
from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo
m = WorkspaceManifest(
    workspace_root='/tmp',
    repos=[
        WorkspaceRepo(repo_name='api', repo_url='https://github.com/org/api.git', role='primary', absolute_path='/tmp/api', branch='main', sparse_paths=[], create_pr=True),
        WorkspaceRepo(repo_name='lib', repo_url='https://github.com/org/lib.git', role='dependency', absolute_path='/tmp/lib', branch='main', sparse_paths=[], create_pr=False),
    ],
    primary_repo_name='api',
)
result = workspace_context_block(m)
assert 'api' in result
assert 'lib' in result
assert '/tmp/api' in result
assert '/tmp/lib' in result
assert len(result) > 0
print('AC-15 PASS')
"

AC-16: pm_task_prompt accepts workspace_manifest parameter

python -c "
import inspect
from swe_af.prompts.product_manager import pm_task_prompt
sig = inspect.signature(pm_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
print('AC-16 PASS')
"

AC-17: architect_task_prompt accepts workspace_manifest parameter

python -c "
import inspect
from swe_af.prompts.architect import architect_task_prompt
sig = inspect.signature(architect_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
print('AC-17 PASS')
"

AC-18: sprint_planner_task_prompt injects target_repo instruction for multi-repo

python -c "
from swe_af.prompts.sprint_planner import sprint_planner_task_prompt
from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo
import inspect
sig = inspect.signature(sprint_planner_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing param: {list(sig.parameters.keys())}'
m = WorkspaceManifest(
    workspace_root='/tmp',
    repos=[
        WorkspaceRepo(repo_name='api', repo_url='https://github.com/org/api.git', role='primary', absolute_path='/tmp/api', branch='main', sparse_paths=[], create_pr=True),
        WorkspaceRepo(repo_name='lib', repo_url='https://github.com/org/lib.git', role='dependency', absolute_path='/tmp/lib', branch='main', sparse_paths=[], create_pr=False),
    ],
    primary_repo_name='api',
)
prompt = sprint_planner_task_prompt(goal='test goal', prd={}, architecture={}, workspace_manifest=m)
assert 'target_repo' in prompt, 'target_repo instruction missing from sprint planner prompt'
print('AC-18 PASS')
"

AC-19: coder_task_prompt injects repo-scope block with correct path for target repo

python -c "
from swe_af.prompts.coder import coder_task_prompt
from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo
import inspect
sig = inspect.signature(coder_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
assert 'target_repo' in sig.parameters, f'Missing target_repo: {list(sig.parameters.keys())}'
m = WorkspaceManifest(
    workspace_root='/tmp',
    repos=[
        WorkspaceRepo(repo_name='api', repo_url='https://github.com/org/api.git', role='primary', absolute_path='/tmp/api', branch='main', sparse_paths=[], create_pr=True),
        WorkspaceRepo(repo_name='lib', repo_url='https://github.com/org/lib.git', role='dependency', absolute_path='/tmp/lib', branch='main', sparse_paths=[], create_pr=False),
    ],
    primary_repo_name='api',
)
prompt = coder_task_prompt(issue={}, architecture={}, workspace_manifest=m, target_repo='lib')
assert '/tmp/lib' in prompt, f'Target repo path missing. Prompt start: {prompt[:500]}'
print('AC-19 PASS')
"

AC-20: verifier_task_prompt accepts workspace_manifest parameter

python -c "
import inspect
from swe_af.prompts.verifier import verifier_task_prompt
sig = inspect.signature(verifier_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
print('AC-20 PASS')
"

AC-21: workspace_setup_task_prompt accepts workspace_manifest parameter

python -c "
import inspect
from swe_af.prompts.workspace import workspace_setup_task_prompt
sig = inspect.signature(workspace_setup_task_prompt)
assert 'workspace_manifest' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
print('AC-21 PASS')
"

AC-22: github_pr_task_prompt accepts all_pr_results parameter

python -c "
import inspect
from swe_af.prompts.github_pr import github_pr_task_prompt
sig = inspect.signature(github_pr_task_prompt)
assert 'all_pr_results' in sig.parameters, f'Missing: {list(sig.parameters.keys())}'
print('AC-22 PASS')
"

AC-23: _clone_repos is importable and has correct signature

python -c "
import inspect
from swe_af.app import _clone_repos
sig = inspect.signature(_clone_repos)
params = list(sig.parameters.keys())
assert 'cfg' in params, f'cfg not in params: {params}'
assert 'artifacts_dir' in params, f'artifacts_dir not in params: {params}'
import asyncio
import inspect as ins
assert ins.iscoroutinefunction(_clone_repos), '_clone_repos must be async'
print('AC-23 PASS')
"

AC-24: BuildConfig rejects duplicate repo names / mount points

python -c "
from swe_af.execution.schemas import BuildConfig, RepoSpec
from pydantic import ValidationError
import sys
try:
    cfg = BuildConfig(repos=[
        RepoSpec(repo_url='https://github.com/org/myrepo.git', role='primary'),
        RepoSpec(repo_url='https://github.com/org/myrepo.git', role='dependency'),
    ])
    sys.exit(1)
except (ValidationError, ValueError):
    pass
print('AC-24 PASS')
"

AC-25: All existing tests pass without modification

python -m pytest tests/ -x -q 2>&1; echo "EXIT:$?"
# Expected last line: EXIT:0

13. Assumptions

  1. Git ≥ 2.25 on PATH — Required for git worktree and git sparse-checkout.
  2. asyncio.to_thread for cloning — No native async git library introduced; subprocess-based clones wrapped in thread executor.
  3. Single workspace root per build — All repos for a given build share one workspace_root directory.
  4. Primary repo drives DAGintegration_branch, initial_commit_sha, git_mode in DAGState continue to refer to primary repo's git state. Dependency repos' git state is tracked in WorkspaceManifest only.
  5. PR creation is sequential, not atomic — Each repo's PR is created one at a time; partial success (some repos succeed, some fail) is a valid build outcome recorded in BuildResult.pr_results.
  6. mount_point uniqueness — Callers are responsible for unique mount_point values (or unique repo names); duplicate mount points raise ValueError in BuildConfig validator.
  7. Prompt injection is sufficient — The coder LLM will correctly operate in the target repo directory when prompted with explicit absolute paths. No additional filesystem sandbox enforcement is required.
  8. sprint_planner_task_prompt calling convention — Existing positional args (goal, prd, architecture) are preserved; workspace_manifest is keyword-only. All existing callers pass arguments positionally or by name — adding a keyword-only parameter is non-breaking.
  9. github_pr_task_prompt calling conventionall_pr_results is keyword-only with default None; existing callers need no changes.
  10. Tests mock subprocess — AC verification tests do not perform real git clones; they validate schema, signatures, and prompt content only.

14. Risks

  1. Risk: Coder agent commits to wrong repo despite prompt injection
    Mitigation: Verifier runs git status on each repo; stray modifications will surface as unexpected changes. Issue advisor can recover via RETRY_APPROACH.

  2. Risk: Parallel clone failures partially initialize workspace
    Mitigation: _clone_repos uses asyncio.gather(..., return_exceptions=True); any exception triggers cleanup of all partially-cloned directories before raising RuntimeError.

  3. Risk: BuildConfig normalization silently wrong for edge cases
    Mitigation: Validator explicitly raises ValueError if both repo_url and non-empty repos are set simultaneously. Documented in docstring.

  4. Risk: Sprint planner generates issues without target_repo in multi-repo builds
    Mitigation: Prompt injection explicitly mandates target_repo. PlannedIssue.target_repo defaults to "" (primary), so even missing values degrade gracefully to primary-repo behavior.

  5. Risk: pr_url property break if BuildResult is deserialized from old checkpoint
    Mitigation: pr_results defaults to []; pr_url property handles empty list with return "". Old checkpoints without pr_results field will deserialize to empty list via Pydantic defaults.

  6. Risk: Directory collision if two repos have same derived name
    Mitigation: BuildConfig.model_validator checks for duplicate derived repo names and raises ValueError before any cloning begins.

  7. Risk: Cross-repo merge conflicts (e.g., shared generated file tracked in two repos)
    Mitigation: Sprint planner's "one issue per repo" constraint prevents cross-repo file ownership. This is a caller responsibility for truly shared files — out of scope for this PRD.


15. Implementation Dependency Graph

Level 0 — No dependencies (implement in parallel):

  • RepoSpec model
  • WorkspaceRepo model
  • WorkspaceManifest model
  • RepoPRResult model
  • workspace_context_block() utility (swe_af/prompts/_utils.py)

Level 1 — Depends on Level 0:

  • BuildConfig extensions (repos, primary_repo, validator) — depends on RepoSpec
  • GitInitResult.repo_name field
  • MergeResult.repo_name field
  • CoderResult.repo_name field
  • PlannedIssue.target_repo field
  • DAGState.workspace_manifest field — depends on WorkspaceManifest
  • BuildResult.pr_results + pr_url property — depends on RepoPRResult

Level 2 — Depends on Level 0 + Level 1:

  • _clone_repos() — depends on BuildConfig, WorkspaceManifest
  • pm_task_prompt extension — depends on workspace_context_block
  • architect_task_prompt extension — depends on workspace_context_block
  • sprint_planner_task_prompt extension — depends on workspace_context_block
  • coder_task_prompt extension — depends on workspace_context_block
  • verifier_task_prompt extension — depends on workspace_context_block
  • github_pr_task_prompt extension — depends on RepoPRResult
  • workspace_setup_task_prompt extension — depends on WorkspaceManifest

Level 3 — Depends on Level 2:

  • app.py build flow: replace single clone block with _clone_repos(), per-repo run_git_init, store manifest in DAGState
  • Merger update: group branches by repo_name, merge into correct repo
  • Per-repo PR loop in app.py

Level 4 — Depends on Level 3:

  • Integration tests validating full multi-repo build() flow with mocked git
  • AC verification suite execution

End of PRD

🏗️ Architecture

Multi-Repo Workspace Architecture

SWE-AF Multi-Repo Support — Revision 3
Addresses all B-NEW-* blockers and C-NEW-* concerns from tech lead review.


Table of Contents

  1. Overview
  2. Baseline Schema Reference
  3. New Pydantic Models
  4. Existing Schema Extensions
  5. Prompt Layer Changes
  6. New Prompt Functions
  7. app.py Changes
  8. dag_executor.py Changes
  9. Detailed Function Specifications
  10. Backward Compatibility
  11. Data Flow Examples
  12. Architectural Decisions
  13. Extension Points

1. Overview

This document specifies all changes needed to enable SWE-AF to accept a list of
repositories (N ≥ 1) per build. The design extends the existing
schema/prompt/orchestration layers with zero behavior change for single-repo callers.

Core invariants:

  • A build always has exactly one "primary" repo. Existing callers passing repo_url/
    repo_path continue to work identically.
  • All new types are defined in swe_af/execution/schemas.py (the canonical shared-types
    module) so no circular imports occur.
  • Prompt functions receive workspace_manifest as a keyword-only parameter with a default
    of None — callers that do not pass it get byte-for-byte identical output to today.
  • One new file is introduced: swe_af/prompts/_utils.py for the shared
    workspace_context_block helper. All other changes go into existing files.

2. Baseline Schema Reference

Existing types referenced by this document (unchanged, defined in
swe_af/execution/schemas.py):

Type Key Fields Relevant to This PRD
ExecutionConfig runtime, models, _resolved_models (PrivateAttr). Properties: ai_provider, git_model, merger_model, integration_tester_model (and all other role-model properties).
BuildConfig runtime, models, repo_url: str = "", enable_github_pr, github_pr_base. Property ai_provider calls _runtime_to_provider(self.runtime). model_config = ConfigDict(extra="forbid").
DAGState repo_path, artifacts_dir, git_integration_branch, git_original_branch, git_initial_commit, git_mode, merged_branches, unmerged_branches, merge_results.
IssueResult issue_name, outcome, result_summary, error_message, files_changed, branch_name, attempts, adaptations, debt_items.
CoderResult files_changed, summary, complete, iteration_id, tests_passed, test_summary, codebase_learnings, agent_retro.
GitInitResult mode, original_branch, integration_branch, initial_commit_sha, success, error_message, remote_url, remote_default_branch.
MergeResult success, merged_branches, failed_branches, conflict_resolutions, merge_commit_sha, pre_merge_sha, needs_integration_test, integration_test_rationale, summary.

ExecutionConfig field clarification (resolves B-NEW-2): ExecutionConfig is a
fully defined class in swe_af/execution/schemas.py (lines 590–693 as of this writing).
The fields used in _merge_level_branches and _init_all_repos are:

  • config.merger_model — property returning self._model_for("merger_model")
  • config.integration_tester_model — property returning self._model_for("integration_tester_model")
  • config.ai_provider — property returning _runtime_to_provider(self.runtime)
  • config.git_model — property returning self._model_for("git_model")

No changes are made to ExecutionConfig.

ai_provider clarification: BuildConfig.ai_provider is an existing property
(not a stored field), defined at line ~529 of schemas.py. It is listed here for
implementer awareness; no changes needed.


3. New Pydantic Models

All models below are added to swe_af/execution/schemas.py.

3.1 _derive_repo_name (module-level private function)

import re

def _derive_repo_name(url: str) -> str:
    """Extract repo name from a git URL.

    Examples:
        'https://github.com/org/my-project.git' -> 'my-project'
        'git@github.com:org/my-project.git'     -> 'my-project'
        'https://github.com/org/repo'           -> 'repo'
    """
    match = re.search(r"/([^/]+?)(?:\.git)?$", url.rstrip("/"))
    return match.group(1) if match else "repo"

This consolidates the identical logic in app.py's _repo_name_from_url (resolves
C-NEW-2). After this is added to schemas.py, app.py replaces its local
definition with:

from swe_af.execution.schemas import _derive_repo_name as _repo_name_from_url

3.2 RepoSpec

from pydantic import BaseModel, ConfigDict, field_validator

class RepoSpec(BaseModel):
    """Input specification for one repository in a multi-repo build."""

    model_config = ConfigDict(extra="forbid")

    repo_url: str = ""
    """GitHub/git HTTPS or SSH URL. Required when repo_path is empty."""

    repo_path: str = ""
    """Absolute path to an already-cloned repo. Skips clone when set."""

    role: str
    """'primary' or 'dependency'. Exactly one repo must be 'primary'."""

    branch: str = ""
    """Branch to checkout. Empty string means use the remote default branch."""

    sparse_paths: list[str] = []
    """For sparse checkout. Empty list means full checkout."""

    mount_point: str = ""
    """Workspace subdirectory name override. Defaults to name derived from
    repo_url. Must be unique across all repos in a BuildConfig."""

    create_pr: bool = True
    """Whether to create a PR for this repo after the build."""

    @field_validator("repo_url")
    @classmethod
    def _validate_url(cls, v: str) -> str:
        if not v:
            return v  # repo_path may be used instead
        valid_prefixes = ("https://", "http://", "git@", "ssh://")
        if not any(v.startswith(p) for p in valid_prefixes):
            raise ValueError(
                f"repo_url must be an https://, http://, git@, or ssh:// URL; got {v!r}"
            )
        return v

    @field_validator("role")
    @classmethod
    def _validate_role(cls, v: str) -> str:
        if v not in ("primary", "dependency"):
            raise ValueError(f"role must be 'primary' or 'dependency'; got {v!r}")
        return v

3.3 WorkspaceRepo

class WorkspaceRepo(BaseModel):
    """State of a single repository that has been cloned into the workspace."""

    model_config = ConfigDict(frozen=False)
    """Mutable so _init_all_repos can backfill git_init_result in-place.
    Resolves B-NEW-4: without frozen=False, Pydantic v2 raises TypeError on
    ws_repo.git_init_result = result assignment."""

    repo_name: str
    """Canonical name, derived from repo_url or mount_point. E.g. 'myrepo'."""

    repo_url: str
    """Original git URL (empty if repo_path-only spec)."""

    role: str
    """'primary' or 'dependency'."""

    absolute_path: str
    """Absolute filesystem path to the cloned repo root."""

    branch: str
    """Actual checked-out branch name (resolved post-clone via git rev-parse)."""

    sparse_paths: list[str] = []
    """Sparse checkout paths, or empty for full checkout."""

    create_pr: bool = True
    """Whether a PR should be created for this repo."""

    git_init_result: dict | None = None
    """Populated by _init_all_repos after run_git_init completes.
    Contains GitInitResult.model_dump() for this repo."""

3.4 WorkspaceManifest

class WorkspaceManifest(BaseModel):
    """Snapshot of all repositories cloned for a multi-repo build."""

    workspace_root: str
    """Absolute path to the parent directory containing all repo subdirectories."""

    repos: list[WorkspaceRepo]
    """All repos in this build. Always at least one entry."""

    primary_repo_name: str
    """repo_name of the primary repo. Guaranteed to match one entry in repos."""

    @property
    def primary_repo(self) -> "WorkspaceRepo":
        """Return the primary WorkspaceRepo. Always present."""
        for r in self.repos:
            if r.repo_name == self.primary_repo_name:
                return r
        raise RuntimeError(
            f"primary_repo_name={self.primary_repo_name!r} not found in manifest"
        )

3.5 RepoPRResult

class RepoPRResult(BaseModel):
    """Result of creating a PR for one repository."""

    repo_name: str
    repo_url: str
    success: bool
    pr_url: str = ""
    pr_number: int = 0
    error_message: str = ""

4. Existing Schema Extensions

All changes add new fields with defaults. No existing fields are renamed or removed.

4.1 BuildConfig Extensions

Add to swe_af/execution/schemas.py, inside BuildConfig:

# New field — add after existing field declarations:
repos: list["RepoSpec"] = []
"""Multi-repo list. Empty means single-repo mode (uses repo_url legacy field).
Populated by the model_validator from legacy repo_url when repos is empty."""

# New model_validator — add after existing _validate_v2_keys:
@model_validator(mode="after")
def _normalize_repos(self) -> "BuildConfig":
    """Normalize legacy repo_url/repo_path into self.repos and validate the list.

    Validation order (stops at first failure):
    1. Mutual exclusion: repo_url + non-empty repos simultaneously -> ValueError
    2. Normalization: empty repos + non-empty repo_url -> synthesize single RepoSpec
    3. Empty passthrough: repos still empty -> return (deferred to build())
    4. Exactly one primary -> ValueError if 0 or 2+
    5. No duplicate repo_url values -> ValueError
    6. No duplicate resolved names -> ValueError
    7. Backfill self.repo_url from primary if currently empty
    """
    caller_set_repos = bool(self.__pydantic_fields_set__ & {"repos"})
    caller_set_url = bool(self.__pydantic_fields_set__ & {"repo_url"})
    if caller_set_url and caller_set_repos and self.repos:
        raise ValueError(
            "Cannot set both repo_url and repos simultaneously. "
            "Use repos=[RepoSpec(...)] for multi-repo builds."
        )

    if not self.repos and self.repo_url:
        self.repos = [RepoSpec(
            repo_url=self.repo_url,
            role="primary",
            create_pr=self.enable_github_pr,
        )]

    if not self.repos:
        return self  # deferred — build() validates repo_path is provided

    primaries = [r for r in self.repos if r.role == "primary"]
    if len(primaries) != 1:
        raise ValueError(
            f"Exactly one repo must have role='primary'; found {len(primaries)}"
        )

    urls = [r.repo_url for r in self.repos if r.repo_url]
    if len(urls) != len(set(urls)):
        raise ValueError("Duplicate repo_url values are not allowed in repos list.")

    def _get_name(spec: "RepoSpec") -> str:
        if spec.mount_point:
            return spec.mount_point
        if spec.repo_url:
            return _derive_repo_name(spec.repo_url)
        return os.path.basename(spec.repo_path.rstrip("/")) if spec.repo_path else "repo"

    names = [_get_name(r) for r in self.repos]
    if len(names) != len(set(names)):
        raise ValueError(
            f"Duplicate repo names/mount_points in repos: {names}. "
            "Set mount_point explicitly to disambiguate."
        )

    if not self.repo_url and primaries:
        # Use object.__setattr__ to bypass Pydantic's immutability on assignment
        object.__setattr__(self, "repo_url", primaries[0].repo_url)

    return self

@property
def primary_repo(self) -> "RepoSpec | None":
    """Return the primary RepoSpec, or None if repos is empty."""
    for r in self.repos:
        if r.role == "primary":
            return r
    return None

model_config = ConfigDict(extra="forbid") compatibility: Adding repos as a
declared field is safe — extra="forbid" only rejects undeclared keys.

os import: schemas.py does not currently import os. Add import os at the top.

4.2 BuildResult Extensions

Replace pr_url: str = "" with:

# REMOVE: pr_url: str = ""
# ADD:
pr_results: list["RepoPRResult"] = []
"""Per-repo PR creation results. Empty list for builds without PR creation."""

@property
def pr_url(self) -> str:
    """Backward-compat alias: returns first successful PR URL, or empty string."""
    for r in self.pr_results:
        if r.success and r.pr_url:
            return r.pr_url
    return ""

def model_dump(self, **kwargs) -> dict:
    """Override to inject computed pr_url property into serialized dict."""
    d = super().model_dump(**kwargs)
    d["pr_url"] = self.pr_url
    return d

pr_url construction-site breakage: Any code that constructs
BuildResult(pr_url='...') will receive a ValidationError since pr_url is no
longer a field. Known sites that must be updated:

  • swe_af/app.py ~line 508: BuildResult(..., pr_url=pr_url) → use pr_results=[...]
  • swe_af/fast/app.py: any BuildResult(pr_url=...) construction → use pr_results=[...]
  • Test files that construct BuildResult(pr_url=...) directly → use pr_results=[...]

Read sites (unchanged): result.pr_url, result.model_dump()["pr_url"],
result_dict.get("pr_url") all continue to work.

4.3 DAGState Extensions

# ADD to DAGState:
workspace_manifest: dict | None = None
"""WorkspaceManifest.model_dump() for multi-repo builds.
None for single-repo builds (backward compat).
Stored as dict (not WorkspaceManifest) so checkpoint JSON serialization works."""

4.4 CoderResult Extensions

# ADD to CoderResult:
repo_name: str = ""
"""Name of the repo this coder ran in. Empty for single-repo builds."""

4.5 GitInitResult Extensions

# ADD to GitInitResult:
repo_name: str = ""
"""Name of the repo this git_init ran for. Empty for single-repo builds."""

4.6 MergeResult Extensions

# ADD to MergeResult:
repo_name: str = ""
"""Name of the repo this merge ran for. Empty for single-repo builds."""

4.7 IssueResult Extensions (resolves B-NEW-1)

# ADD to IssueResult:
repo_name: str = ""
"""Name of the repo this issue was coded in.
Propagated from CoderResult.repo_name by coding_loop.py when recording
coding results. Backfilled from issue['target_repo'] by dag_executor.py
in _execute_level if CoderResult.repo_name is empty.
Used by _merge_level_branches to group branches by repo."""

4.8 PlannedIssue Extensions

Added to swe_af/reasoners/schemas.py:

# ADD to PlannedIssue:
target_repo: str = ""
"""Repo name this issue should be coded in.
Empty means primary repo (backward compat).
Set by sprint planner in multi-repo builds via sprint_planner_task_prompt."""

5. Prompt Layer Changes

5.1 New File: swe_af/prompts/_utils.py

"""Shared prompt utilities for multi-repo context injection."""
from __future__ import annotations

from swe_af.execution.schemas import WorkspaceManifest


def workspace_context_block(manifest: "WorkspaceManifest | None") -> str:
    """Return a markdown workspace context block for injection into prompts.

    Returns empty string ("") when:
    - manifest is None
    - manifest.repos has exactly one entry (single-repo build)

    For multi-repo builds (len(repos) > 1), returns:

        ## Workspace Repositories
        | Repo | Role | Path | Branch |
        |------|------|------|--------|
        | api (PRIMARY) | primary | /tmp/api | main |
        | lib | dependency | /tmp/lib | develop |

    AC-14: single-repo manifest -> returns ""
    AC-15: multi-repo manifest -> returns string containing repo names and paths

    Note: The PRD §7.1 format spec shows a Branch column; we include it here.
    AC-15 does not test for branch presence but the column is present per spec.
    """
    if manifest is None or len(manifest.repos) <= 1:
        return ""

    lines: list[str] = [
        "## Workspace Repositories",
        "| Repo | Role | Path | Branch |",
        "|------|------|------|--------|",
    ]
    for repo in manifest.repos:
        if repo.repo_name == manifest.primary_repo_name:
            label = f"{repo.repo_name} (PRIMARY)"
        else:
            label = repo.repo_name
        lines.append(
            f"| {label} | {repo.role} | {repo.absolute_path} | {repo.branch} |"
        )

    return "\n".join(lines)

6. New Prompt Functions

The existing *_prompts() functions returning (system_prompt, task_prompt) tuples
are unchanged. The new *_task_prompt() functions add workspace_manifest as a
keyword-only parameter with default None.

6.1 pm_task_promptswe_af/prompts/product_manager.py

from swe_af.execution.schemas import WorkspaceManifest
from swe_af.prompts._utils import workspace_context_block

def pm_task_prompt(
    *,
    goal: str,
    repo_path: str,
    prd_path: str,
    additional_context: str = "",
    workspace_manifest: "WorkspaceManifest | None" = None,
) -> str:
    """Task prompt for the product manager agent.

    Returns the same content as product_manager_prompts()[1] with optional
    workspace context block injected after the Repository section.
    """
    context_block = ""
    if additional_context:
        context_block = f"\n## Additional Context\n{additional_context}\n"

    ws_block = workspace_context_block(workspace_manifest)
    ws_section = f"\n{ws_block}\n" if ws_block else ""

    return (
        f"## Goal\n{goal}\n\n"
        f"## Repository\n{repo_path}\n"
        f"{context_block}"
        f"{ws_section}"
        "## How Your PRD Will Be Used\n\n"
        "1. An architect designs the technical solution from your PRD\n"
        "2. A sprint planner decomposes into independent issues with a dependency graph\n"
        "3. Issues at the same dependency level execute IN PARALLEL by isolated agents\n"
        "4. A QA agent verifies each acceptance criterion LITERALLY by running commands\n\n"
        "Write acceptance criteria as test assertions, not human briefings.\n\n"
        "## Your Mission\n\n"
        "Produce a PRD for this goal. Read the codebase first — understand the current\n"
        "state deeply before defining what needs to change.\n\n"
        f"Write your full PRD to: {prd_path}\n\n"
        "The bar: an engineering team of autonomous agents can execute this PRD without\n"
        "asking a single clarifying question.\n"
    )

6.2 architect_task_promptswe_af/prompts/architect.py

from swe_af.execution.schemas import WorkspaceManifest
from swe_af.prompts._utils import workspace_context_block

def architect_task_prompt(
    *,
    prd: "PRD",
    repo_path: str,
    prd_path: str,
    architecture_path: str,
    feedback: "str | None" = None,
    workspace_manifest: "WorkspaceManifest | None" = None,
) -> str:
    """Task prompt for the architect agent.

    Builds same content as architect_prompts()[1] with optional workspace
    context injected after the Repository section.
    """
    ac_formatted = "\n".join(f"- {c}" for c in prd.acceptance_criteria)
    must_have = "\n".join(f"- {m}" for m in prd.must_have)
    out_of_scope = "\n".join(f"- {o}" for o in prd.out_of_scope)

    feedback_block = ""
    if feedback:
        feedback_block = (
            "\n## Revision Feedback from Tech Lead\n"
            "The previous architecture was reviewed and needs revision:\n"
            f"{feedback}\n"
            "Address these concerns directly.\n"
        )

    ws_block = workspace_context_block(workspace_manifest)
    ws_section = f"\n{ws_block}\n" if ws_block else ""

    return (
        f"## Product Requirements\n{prd.validated_description}\n\n"
        f"## Acceptance Criteria\n{ac_formatted}\n\n"
        f"## Scope\n- Must have:\n{must_have}\n- Out of scope:\n{out_of_scope}\n\n"
        f"## Repository\n{repo_path}\n"
        f"{ws_section}"
        f"The full PRD is at: {prd_path}\n"
        f"{feedback_block}"
        "## Your Mission\n\n"
        "Design the technical architecture. Read the codebase deeply first — your design\n"
        "should feel like a natural extension of what already exists.\n\n"
        f"Write your architecture document to: {architecture_path}\n\n"
        "The bar: this document is the single source of truth. Every interface you define\n"
        "will be copied verbatim into code.\n"
    )

6.3 sprint_planner_task_promptswe_af/prompts/sprint_planner.py

This is the most critical multi-repo prompt. The function builds the full task prompt
string (equivalent to sprint_planner_prompts()[1]) and appends multi-repo mandates.

from swe_af.execution.schemas import WorkspaceManifest
from swe_af.prompts._utils import workspace_context_block

def sprint_planner_task_prompt(
    *,
    goal: str,
    prd: "dict | PRD",
    architecture: "dict | Architecture",
    workspace_manifest: "WorkspaceManifest | None" = None,
    prd_path: str = "",
    architecture_path: str = "",
    repo_path: str = "",
) -> str:
    """Task prompt for the sprint planner agent.

    Builds the full sprint planner task prompt. Equivalent to
    sprint_planner_prompts()[1] with:
    - workspace_manifest keyword parameter added
    - Multi-repo mandate section injected when len(repos) > 1

    Multi-repo mandate ensures:
    - Each PlannedIssue sets target_repo to the repo it codes in
    - Cross-repo work is split into separate issues

    AC-18: 'target_repo' must appear in the returned string for multi-repo
    manifests (verified by the test). This is satisfied by the mandate section.
    """
    # Accept both Pydantic model and dict forms
    if hasattr(prd, "validated_description"):
        desc = prd.validated_description
        ac = prd.acceptance_criteria
    else:
        desc = prd.get("validated_description", goal)
        ac = prd.get("acceptance_criteria", [])

    if hasattr(architecture, "summary"):
        arch_summary = architecture.summary
    else:
        arch_summary = architecture.get("summary", "")

    ac_formatted = "\n".join(f"- {c}" for c in ac)

    ws_block = workspace_context_block(workspace_manifest)
    ws_section = f"\n{ws_block}\n" if ws_block else ""

    # Reference docs section
    ref_docs = ""
    if prd_path or architecture_path:
        ref_docs = "\n## Reference Documents\n"
        if prd_path:
            ref_docs += f"- Full PRD: {prd_path}\n"
        if architecture_path:
            ref_docs += f"- Architecture: {architecture_path}\n"

    repo_section = f"\n## Repository\n{repo_path}\n" if repo_path else ""

    # Multi-repo mandate — injected only when multiple repos present
    multi_repo_section = ""
    if workspace_manifest and len(workspace_manifest.repos) > 1:
        repo_names = [r.repo_name for r in workspace_manifest.repos]
        primary = workspace_manifest.primary_repo_name
        multi_repo_section = (
            "\n## Multi-Repo Mandate\n\n"
            "This build spans multiple repositories. For EVERY PlannedIssue:\n\n"
            f"1. **Set `target_repo`** to the repo name this issue codes in.\n"
            f"   Valid repo names: {repo_names}\n"
            f"   Primary repo (default): '{primary}'\n"
            "2. **One repo per issue**: do not create an issue that modifies files in\n"
            "   multiple repos simultaneously. If work spans repos, split into separate\n"
            "   issues with explicit `depends_on` edges.\n"
            "3. **Cross-repo dependencies**: an issue in repo B that consumes an interface\n"
            "   from repo A must list the repo-A issue in `depends_on`.\n"
        )

    return (
        f"## Goal\n{desc}\n\n"
        f"## Acceptance Criteria\n{ac_formatted}\n\n"
        f"## Architecture Summary\n{arch_summary}\n"
        f"{ws_section}"
        f"{ref_docs}"
        f"{repo_section}"
        f"{multi_repo_section}"
        "\n## Your Mission\n\n"
        "Break this work into issues executable by autonomous coder agents.\n\n"
        "Read the codebase, PRD, and architecture document thoroughly. The architecture\n"
        "document is your source of truth for all types, interfaces, and component\n"
        "boundaries.\n\n"
        "DO NOT write issue .md files. A separate parallel agent pool does that.\n\n"
        "For each issue provide: name, title, 2-3 sentence description (WHAT not HOW),\n"
        "depends_on, provides, files_to_create, files_to_modify, acceptance_criteria,\n"
        "testing_strategy, and guidance.\n\n"
        "Minimize the critical path. Maximize parallelism. Every PRD acceptance\n"
        "criterion must map to at least one issue.\n"
    )

6.4 coder_task_promptswe_af/prompts/coder.py

The existing coder_task_prompt signature is extended with two new keyword parameters.
All existing parameters and the entire existing function body are preserved.

from swe_af.execution.schemas import WorkspaceManifest
from swe_af.prompts._utils import workspace_context_block

def coder_task_prompt(
    issue: dict,
    worktree_path: str = "",
    feedback: str = "",
    iteration: int = 1,
    project_context: "dict | None" = None,
    memory_context: "dict | None" = None,
    workspace_manifest: "WorkspaceManifest | None" = None,   # NEW
    target_repo: str = "",                                    # NEW
) -> str:
    """Task prompt for the coder agent.

    New parameters:
        workspace_manifest: When provided for a multi-repo build, a CRITICAL
            directive block is prepended specifying which repo to code in.
        target_repo: Repo name (e.g. 'lib'). When set with a multi-repo
            manifest, the directive includes the absolute path of that repo.

    AC-19: When target_repo='lib' and manifest has lib at '/tmp/lib',
    the returned string contains '/tmp/lib'.
    """
    project_context = project_context or {}
    memory_context = memory_context or {}
    sections: list[str] = []

    # Multi-repo CRITICAL directive block — prepended before issue details
    if workspace_manifest and len(workspace_manifest.repos) > 1 and target_repo:
        target_path = ""
        for r in workspace_manifest.repos:
            if r.repo_name == target_repo:
                target_path = r.absolute_path
                break
        if not target_path:
            target_path = worktree_path  # fallback

        sections.append(
            f"## CRITICAL: Target Repository\n"
            f"You are working in the **{target_repo}** repository.\n"
            f"- Absolute path: `{target_path}`\n"
            "- ALL git operations (commit, status, add) must be run from this path.\n"
            "- Do NOT modify files in other repositories.\n"
        )
        ws_block = workspace_context_block(workspace_manifest)
        if ws_block:
            sections.append(ws_block)
        sections.append("")  # blank line separator

    # --- Existing body begins here (unchanged) ---
    sections.append("## Issue to Implement")
    sections.append(f"- **Name**: {issue.get('name', '(unknown)')}")
    sections.append(f"- **Title**: {issue.get('title', '(unknown)')}")

    ac = issue.get("acceptance_criteria", [])
    if ac:
        sections.append("- **Acceptance Criteria**:")
        sections.extend(f"  - [ ] {c}" for c in ac)

    deps = issue.get("depends_on", [])
    if deps:
        sections.append(f"- **Dependencies**: {deps}")

    provides = issue.get("provides", [])
    if provides:
        sections.append(f"- **Provides**: {provides}")

    files_create = issue.get("files_to_create", [])
    files_modify = issue.get("files_to_modify", [])
    if files_create:
        sections.append(f"- **Files to create**: {files_create}")
    if files_modify:
        sections.append(f"- **Files to modify**: {files_modify}")

    testing_strategy = issue.get("testing_strategy", "")
    if testing_strategy:
        sections.append(f"- **Testing Strategy**: {testing_strategy}")

    guidance = issue.get("guidance") or {}
    testing_guidance = guidance.get("testing_guidance", "")
    if testing_guidance:
        sections.append(f"- **Testing Guidance (from sprint planner)**: {testing_guidance}")

    if project_context:
        sections.append("\n## Project Context")
        prd_path = project_context.get("prd_path", "")
        arch_path = project_context.get("architecture_path", "")
        issues_dir = project_context.get("issues_dir", "")
        if prd_path or arch_path or issues_dir:
            sections.append("### Key Files")
            if prd_path:
                sections.append(f"- PRD: `{prd_path}`")
            if arch_path:
                sections.append(f"- Architecture: `{arch_path}`")
            if issues_dir:
                sections.append(f"- Issue files: `{issues_dir}/`")

    conventions = memory_context.get("codebase_conventions")
    if conventions:
        sections.append("\n## Codebase Conventions (from prior issues)")
        if isinstance(conventions, dict):
            for k, v in conventions.items():
                sections.append(f"- **{k}**: {v}")
        elif isinstance(conventions, list):
            sections.extend(f"- {c}" for c in conventions)

    failure_patterns = memory_context.get("failure_patterns")
    if failure_patterns:
        sections.append("\n## Known Failure Patterns (avoid these)")
        for fp in failure_patterns[:5]:
            sections.append(
                f"- **{fp.get('pattern', '?')}** ({fp.get('issue', '?')}): "
                f"{fp.get('description', '')}"
            )

    dep_interfaces = memory_context.get("dependency_interfaces")
    if dep_interfaces:
        sections.append("\n## Dependency Interfaces (completed upstream issues)")
        for iface in dep_interfaces:
            sections.append(f"- **{iface.get('issue', '?')}**: {iface.get('summary', '')}")
            exports = iface.get("exports", [])
            if exports:
                sections.extend(f"  - `{e}`" for e in exports[:5])

    bug_patterns = memory_context.get("bug_patterns")
    if bug_patterns:
        sections.append("\n## Common Bug Patterns in This Build")
        for bp in bug_patterns[:5]:
            sections.append(
                f"- {bp.get('type', '?')} (seen {bp.get('frequency', 0)}x "
                f"in {bp.get('modules', [])})"
            )

    failure_notes = issue.get("failure_notes", [])
    if failure_notes:
        sections.append("\n## Upstream Failure Notes")
        sections.extend(f"- {note}" for note in failure_notes)

    integration_branch = issue.get("integration_branch", "")
    if integration_branch:
        sections.append("\n## Git Context")
        sections.append(f"- Integration branch: `{integration_branch}`")
        sections.append(f"- Working in worktree: `{worktree_path}`")

    sections.append(f"\n## Working Directory\n`{worktree_path}`")
    sections.append(f"\n## Iteration: {iteration}")

    if feedback:
        sections.append("\n## Feedback from Previous Iteration")
        sections.append("Address ALL of the following issues from the review:\n")
        sections.append(feedback)
        sections.append(
            "\nFix the issues above, then re-commit. Focus on the specific "
            "problems identified — do not rewrite code that is already correct."
        )
    else:
        sections.append(
            "\n## Your Task\n"
            "1. Explore the codebase to understand patterns and context.\n"
            "2. Implement the solution per the acceptance criteria.\n"
            "3. Write or update tests per the Testing Strategy/guidance.\n"
            "4. Run tests and report results (tests_passed, test_summary).\n"
            "5. Commit your changes.\n"
            "6. Report codebase_learnings and agent_retro in your output."
        )

    return "\n".join(sections)

6.5 verifier_task_promptswe_af/prompts/verifier.py

# ADD to existing verifier_task_prompt signature:
def verifier_task_prompt(
    prd: dict,
    artifacts_dir: str,
    completed_issues: list[dict],
    failed_issues: list[dict],
    skipped_issues: list[str],
    build_health: "dict | None" = None,
    workspace_manifest: "WorkspaceManifest | None" = None,  # NEW
) -> str:
    # ... existing body unchanged ...
    # ADD at end, before final return "\n".join(sections):
    ws_block = workspace_context_block(workspace_manifest)
    if ws_block:
        sections.append(f"\n{ws_block}")
    # return "\n".join(sections)

Imports to add at top of verifier.py:

from swe_af.execution.schemas import WorkspaceManifest
from swe_af.prompts._utils import workspace_context_block

6.6 workspace_setup_task_promptswe_af/prompts/workspace.py

def workspace_setup_task_prompt(
    repo_path: str,
    integration_branch: str,
    issues: list[dict],
    worktrees_dir: str,
    build_id: str = "",
    workspace_manifest: "WorkspaceManifest | None" = None,  # NEW
) -> str:
    # ... existing body unchanged ...
    # ADD before final return:
    if workspace_manifest and len(workspace_manifest.repos) > 1:
        sections.append("\n## Per-Repo Paths")
        for r in workspace_manifest.repos:
            sections.append(f"- `{r.repo_name}` ({r.role}): `{r.absolute_path}`")
    return "\n".join(sections)

6.7 github_pr_task_promptswe_af/prompts/github_pr.py

def github_pr_task_prompt(
    *,
    repo_path: str,
    integration_branch: str,
    base_branch: str,
    goal: str,
    build_summary: str = "",
    completed_issues: "list[dict] | None" = None,
    accumulated_debt: "list[dict] | None" = None,
    all_pr_results: "list[RepoPRResult] | None" = None,  # NEW
) -> str:
    # ... existing body unchanged ...
    # ADD cross-repo PR section when applicable:
    if all_pr_results and len(all_pr_results) > 1:
        sections.append("\n### Cross-Repo PRs")
        sections.append("Reference these sibling PRs in the PR body:")
        for r in all_pr_results:
            if r.pr_url:
                sections.append(f"- `{r.repo_name}`: {r.pr_url}")
    # ... rest unchanged ...

Import to add at top of github_pr.py:

from swe_af.execution.schemas import RepoPRResult

6.8 __init__.py Update

Add workspace_context_block to swe_af/prompts/__init__.py exports:

from swe_af.prompts._utils import workspace_context_block

__all__ = [
    # ... existing exports ...
    "workspace_context_block",
    # new task prompt functions:
    "pm_task_prompt",
    "architect_task_prompt",
    "sprint_planner_task_prompt",
]

7. app.py Changes

7.1 Imports

# REMOVE local definition:
# def _repo_name_from_url(url: str) -> str: ...

# ADD to imports:
from swe_af.execution.schemas import (
    BuildConfig, BuildResult,
    RepoPRResult, WorkspaceManifest,
    _derive_repo_name as _repo_name_from_url,
)

7.2 _clone_repos — New Async Function

async def _clone_repos(
    cfg: BuildConfig,
    artifacts_dir: str,
) -> WorkspaceManifest:
    """Clone all repos from cfg.repos concurrently. Returns a WorkspaceManifest.

    Parameters:
        cfg: BuildConfig with .repos list populated. len(cfg.repos) >= 1.
        artifacts_dir: Absolute path used to derive workspace_root as its parent.

    Returns:
        WorkspaceManifest with one WorkspaceRepo per RepoSpec.
        All WorkspaceRepo.git_init_result fields are None at this stage
        (populated later by _init_all_repos in dag_executor.py).

    Raises:
        RuntimeError: If any git clone subprocess fails. Partially-cloned
            directories are removed (shutil.rmtree) before raising, so no
            orphaned workspace directories remain.

    Concurrency model:
        asyncio.gather([asyncio.to_thread(blocking_clone), ...]) for all N repos.
        Branch resolution also runs concurrently via asyncio.to_thread.
    """
    import shutil
    from swe_af.execution.schemas import WorkspaceRepo

    workspace_root = os.path.join(os.path.dirname(artifacts_dir), "workspace")
    os.makedirs(workspace_root, exist_ok=True)

    cloned_paths: list[str] = []

    async def _clone_single(spec: "RepoSpec") -> tuple[str, str]:
        """Clone or resolve one repo. Returns (repo_name, absolute_path)."""
        name = (
            spec.mount_point
            or (_repo_name_from_url(spec.repo_url) if spec.repo_url
                else os.path.basename(spec.repo_path.rstrip("/")))
        )
        dest = os.path.join(workspace_root, name)

        # If repo_path given, use it directly — no clone needed (resolves C-NEW-1)
        if spec.repo_path:
            return name, spec.repo_path

        git_dir = os.path.join(dest, ".git")
        if spec.repo_url and not os.path.exists(git_dir):
            os.makedirs(dest, exist_ok=True)
            cmd = ["git", "clone", spec.repo_url, dest]
            if spec.branch:
                cmd += ["--branch", spec.branch]

            def _run() -> subprocess.CompletedProcess:
                return subprocess.run(cmd, capture_output=True, text=True)

            proc = await asyncio.to_thread(_run)
            if proc.returncode != 0:
                raise RuntimeError(
                    f"git clone {spec.repo_url!r} failed "
                    f"(exit {proc.returncode}): {proc.stderr.strip()}"
                )
            cloned_paths.append(dest)

        return name, dest

    async def _resolve_branch(spec: "RepoSpec", path: str) -> str:
        """Resolve actual checked-out branch via git rev-parse (resolves C-NEW-1).

        Falls back to spec.branch or 'HEAD' on error.
        """
        if spec.repo_path and not spec.repo_url:
            # Existing repo — query actual branch
            pass
        def _run() -> str:
            r = subprocess.run(
                ["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
                capture_output=True, text=True,
            )
            if r.returncode == 0 and r.stdout.strip():
                return r.stdout.strip()
            return spec.branch or "HEAD"
        return await asyncio.to_thread(_run)

    # Clone all repos concurrently
    clone_tasks = [_clone_single(spec) for spec in cfg.repos]
    clone_results = await asyncio.gather(*clone_tasks, return_exceptions=True)

    # Check for failures, cleanup partial clones
    errors = [
        (i, r) for i, r in enumerate(clone_results) if isinstance(r, Exception)
    ]
    if errors:
        for p in cloned_paths:
            shutil.rmtree(p, ignore_errors=True)
        msgs = "; ".join(str(r) for _, r in errors)
        raise RuntimeError(f"Multi-repo clone failed: {msgs}")

    # Resolve branches concurrently
    branch_tasks = [
        _resolve_branch(cfg.repos[i], clone_results[i][1])
        for i in range(len(cfg.repos))
    ]
    branches = await asyncio.gather(*branch_tasks, return_exceptions=True)

    # Build WorkspaceRepo list
    repos: list[WorkspaceRepo] = []
    primary_repo_name = ""

    for i, spec in enumerate(cfg.repos):
        name, path = clone_results[i]
        branch = branches[i] if isinstance(branches[i], str) else (spec.branch or "HEAD")
        ws_repo = WorkspaceRepo(
            repo_name=name,
            repo_url=spec.repo_url,
            role=spec.role,
            absolute_path=path,
            branch=branch,
            sparse_paths=spec.sparse_paths,
            create_pr=spec.create_pr,
            git_init_result=None,
        )
        repos.append(ws_repo)
        if spec.role == "primary":
            primary_repo_name = name

    return WorkspaceManifest(
        workspace_root=workspace_root,
        repos=repos,
        primary_repo_name=primary_repo_name,
    )

7.3 build() Modifications

The following changes are made to the existing build() function body, preserving all
existing logic for the single-repo path:

A. After BuildConfig construction, before plan + git_init:

manifest: WorkspaceManifest | None = None

# Multi-repo path: clone all repos concurrently
if len(cfg.repos) > 1:
    app.note(
        f"Cloning {len(cfg.repos)} repos concurrently",
        tags=["build", "clone", "multi-repo"],
    )
    manifest = await _clone_repos(cfg, abs_artifacts_dir)
    # Use primary repo as the canonical repo_path
    repo_path = manifest.primary_repo.absolute_path
    app.note(
        f"Multi-repo workspace ready: {manifest.workspace_root}",
        tags=["build", "clone", "multi-repo", "complete"],
    )
# Single-repo path: existing clone logic unchanged (cfg.repos has 0 or 1 entry)

B. Pass workspace_manifest to execute():

dag_result = _unwrap(await app.call(
    f"{NODE_ID}.execute",
    plan_result=plan_result,
    repo_path=repo_path,
    execute_fn_target=cfg.execute_fn_target,
    config=exec_config,
    git_config=git_config,
    build_id=build_id,
    workspace_manifest=manifest.model_dump() if manifest else None,  # NEW
), "execute")

C. Per-repo PR creation loop (replaces existing single pr_url block):

pr_results: list[RepoPRResult] = []

if manifest and len(manifest.repos) > 1:
    # Multi-repo: one PR per repo where create_pr=True
    for ws_repo in manifest.repos:
        if not ws_repo.create_pr or not cfg.enable_github_pr:
            continue
        repo_git_init = ws_repo.git_init_result or {}
        repo_remote_url = repo_git_init.get("remote_url", "") or ws_repo.repo_url
        if not repo_remote_url:
            continue
        repo_integration_branch = repo_git_init.get("integration_branch", "")
        if not repo_integration_branch:
            continue
        repo_base_branch = (
            cfg.github_pr_base
            or repo_git_init.get("remote_default_branch", "")
            or "main"
        )
        try:
            pr_r = _unwrap(await app.call(
                f"{NODE_ID}.run_github_pr",
                repo_path=ws_repo.absolute_path,
                integration_branch=repo_integration_branch,
                base_branch=repo_base_branch,
                goal=goal,
                build_summary=build_summary,
                completed_issues=[
                    r for r in dag_result.get("completed_issues", [])
                    if not r.get("repo_name") or r.get("repo_name") == ws_repo.repo_name
                ],
                accumulated_debt=dag_result.get("accumulated_debt", []),
                artifacts_dir=plan_result.get("artifacts_dir", artifacts_dir),
                model=resolved["git_model"],
                permission_mode=cfg.permission_mode,
                ai_provider=cfg.ai_provider,
            ), "run_github_pr")
            pr_results.append(RepoPRResult(
                repo_name=ws_repo.repo_name,
                repo_url=ws_repo.repo_url,
                success=pr_r.get("success", False),
                pr_url=pr_r.get("pr_url", ""),
                pr_number=pr_r.get("pr_number", 0),
                error_message=pr_r.get("error_message", ""),
            ))
        except Exception as e:
            pr_results.append(RepoPRResult(
                repo_name=ws_repo.repo_name,
                repo_url=ws_repo.repo_url,
                success=False,
                error_message=str(e),
            ))
else:
    # Single-repo: existing PR logic, wrap result in RepoPRResult
    # ... (existing pr_url resolution code) ...
    # pr_url = <result of existing logic>
    if pr_url:
        pr_results.append(RepoPRResult(
            repo_name=_repo_name_from_url(cfg.repo_url) if cfg.repo_url else "repo",
            repo_url=cfg.repo_url,
            success=True,
            pr_url=pr_url,
            pr_number=pr_result.get("pr_number", 0) if 'pr_result' in dir() else 0,
        ))

NOTE on B-NEW-3 resolution: The previous architecture draft called
_make_legacy_ws_repo(repo_path, cfg) — a function that was never defined. This
architecture eliminates that call entirely. The single-repo PR path continues to
use the existing pr_url string variable directly, wrapping it in RepoPRResult
at the end of the existing block.

D. Return BuildResult:

return BuildResult(
    plan_result=plan_result,
    dag_state=dag_result,
    verification=verification,
    success=success,
    summary=...,
    pr_results=pr_results,  # replaces pr_url=pr_url
).model_dump()

7.4 execute() Function Changes

@app.reasoner()
async def execute(
    plan_result: dict,
    repo_path: str,
    execute_fn_target: str = "",
    config: "dict | None" = None,
    git_config: "dict | None" = None,
    resume: bool = False,
    build_id: str = "",
    workspace_manifest: "dict | None" = None,  # NEW (resolves C-NEW-4)
) -> dict:
    """Execute a planned DAG with self-healing replanning."""
    from swe_af.execution.dag_executor import run_dag
    from swe_af.execution.schemas import ExecutionConfig

    effective_config = dict(config) if config else {}
    exec_config = ExecutionConfig(**effective_config) if effective_config else ExecutionConfig()

    if execute_fn_target:
        async def execute_fn(issue, dag_state):
            return await app.call(
                execute_fn_target,
                issue=issue,
                repo_path=dag_state.repo_path,
            )
    else:
        execute_fn = None

    # Build dag_state via existing _init_dag_state, then inject workspace_manifest
    # (run_dag calls _init_dag_state internally; we pass workspace_manifest through
    #  the dag_state after construction)
    state = await run_dag(
        plan_result=plan_result,
        repo_path=repo_path,
        execute_fn=execute_fn,
        config=exec_config,
        note_fn=app.note,
        call_fn=app.call,
        node_id=NODE_ID,
        git_config=git_config,
        resume=resume,
        build_id=build_id,
        workspace_manifest=workspace_manifest,  # NEW — passed to run_dag
    )
    return state.model_dump()

run_dag signature change: run_dag gains workspace_manifest: dict | None = None
parameter. Internally, immediately after _init_dag_state() constructs dag_state,
it assigns dag_state.workspace_manifest = workspace_manifest.


8. dag_executor.py Changes

8.1 run_dag Signature

async def run_dag(
    plan_result: dict,
    repo_path: str,
    execute_fn: "Callable | None",
    config: ExecutionConfig,
    note_fn: "Callable | None" = None,
    call_fn: "Callable | None" = None,
    node_id: str = "swe-planner",
    git_config: "dict | None" = None,
    resume: bool = False,
    build_id: str = "",
    workspace_manifest: "dict | None" = None,  # NEW
) -> DAGState:
    # ... existing body ...
    # After _init_dag_state():
    dag_state.workspace_manifest = workspace_manifest

    # After git init (when workspace_manifest is set), run per-repo git init:
    if workspace_manifest and call_fn:
        await _init_all_repos(
            dag_state=dag_state,
            call_fn=call_fn,
            node_id=node_id,
            git_model=config.git_model,
            ai_provider=config.ai_provider,
            build_id=build_id,
        )
    # ... rest of run_dag unchanged ...

8.2 _init_all_repos — New Async Helper

async def _init_all_repos(
    dag_state: DAGState,
    call_fn: Callable,
    node_id: str,
    git_model: str,
    ai_provider: str,
    permission_mode: str = "",
    build_id: str = "",
) -> None:
    """Run git_init concurrently for all repos in workspace_manifest.

    Resolves B-NEW-2 (ExecutionConfig clarified above) and B-NEW-4 (WorkspaceRepo
    mutability via ConfigDict(frozen=False)).

    The 'resolved' dict from build() is NOT threaded through here. Instead,
    the relevant model strings are passed directly as typed parameters:
      - git_model: from config.git_model (ExecutionConfig property)
      - ai_provider: from config.ai_provider (ExecutionConfig property)

    These are provided by run_dag which has access to the ExecutionConfig.

    Args:
        dag_state: Mutated in-place. dag_state.workspace_manifest must be a
            dict (WorkspaceManifest.model_dump()) set before calling.
        call_fn: AgentField call function for invoking run_git_init.
        node_id: e.g. 'swe-planner'.
        git_model: Resolved model string. Source: config.git_model.
        ai_provider: 'claude' or 'opencode'. Source: config.ai_provider.
        permission_mode: Forwarded to run_git_init. Source: dag_state has no
            permission_mode field — pass empty string for now; callers that
            need it can add it to run_dag's parameter list.
        build_id: Forwarded to run_git_init for branch namespace isolation.

    Postcondition:
        dag_state.workspace_manifest is updated with git_init_result populated
        on each WorkspaceRepo entry.
    """
    if dag_state.workspace_manifest is None:
        return  # single-repo path: git_init already ran in build()

    from swe_af.execution.schemas import WorkspaceManifest, WorkspaceRepo

    manifest = WorkspaceManifest(**dag_state.workspace_manifest)

    async def _init_one(ws_repo: WorkspaceRepo) -> tuple[str, dict]:
        result = await call_fn(
            f"{node_id}.run_git_init",
            repo_path=ws_repo.absolute_path,
            goal="",  # goal not needed for dependency repos
            artifacts_dir=dag_state.artifacts_dir,
            model=git_model,
            permission_mode=permission_mode,
            ai_provider=ai_provider,
            build_id=build_id,
        )
        return ws_repo.repo_name, result

    tasks = [_init_one(r) for r in manifest.repos]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # Write results back (WorkspaceRepo is mutable: model_config = ConfigDict(frozen=False))
    repo_map = {r.repo_name: r for r in manifest.repos}
    for item in results:
        if isinstance(item, Exception):
            if note_fn:  # note_fn may not be available in this helper
                pass
            continue  # non-fatal: single-repo git_init failure is already non-fatal
        name, git_init_dict = item
        if name in repo_map:
            repo_map[name].git_init_result = git_init_dict

    # Replace dag_state manifest dict with updated version
    dag_state.workspace_manifest = manifest.model_dump()

note_fn availability: _init_all_repos does not receive note_fn in this
signature to keep it simple. If logging is needed, add note_fn: Callable | None = None
as an optional parameter and pass it from run_dag.

8.3 IssueResult.repo_name Propagation (resolves B-NEW-1)

In swe_af/execution/coding_loop.py, in run_coding_loop(), when constructing the
success IssueResult:

# When recording a successful coding loop result:
return IssueResult(
    issue_name=issue["name"],
    outcome=IssueOutcome.COMPLETED,
    result_summary=coder_result.summary,
    files_changed=coder_result.files_changed,
    branch_name=issue.get("branch_name", ""),
    repo_name=coder_result.repo_name,   # propagated from CoderResult
    # ... other fields ...
)

In swe_af/execution/dag_executor.py, in _execute_level(), after results are gathered:

for i, result in enumerate(results):
    if isinstance(result, IssueResult):
        # Backfill repo_name from issue's target_repo if CoderResult didn't set it
        if not result.repo_name:
            result.repo_name = active_issues[i].get("target_repo", "")
        # ... existing classification logic ...

This ensures _merge_level_branches can safely read r.repo_name from any
IssueResult in level_result.completed.

8.4 Per-Repo Merger Dispatch

_merge_level_branches is augmented with a multi-repo dispatch path. The existing
single-repo path is preserved unchanged:

async def _merge_level_branches(
    dag_state: DAGState,
    level_result: LevelResult,
    call_fn: Callable,
    node_id: str,
    config: ExecutionConfig,
    issue_by_name: dict,
    file_conflicts: list[dict],
    note_fn: "Callable | None" = None,
) -> "dict | None":
    """Merge completed branches, dispatching per-repo when manifest is present."""

    # Single-repo path: unchanged
    if dag_state.workspace_manifest is None:
        # ... existing _merge_level_branches body verbatim ...
        pass

    # Multi-repo path: group by repo_name, one merger call per repo
    from swe_af.execution.schemas import WorkspaceManifest
    manifest = WorkspaceManifest(**dag_state.workspace_manifest)

    # Group IssueResults by repo_name (fall back to primary if empty)
    by_repo: dict[str, list] = {}
    for r in level_result.completed:
        if r.branch_name:
            repo = r.repo_name or manifest.primary_repo_name
            by_repo.setdefault(repo, []).append(r)

    if not by_repo:
        return None

    async def _call_merger_for_repo(
        repo_name: str,
        issue_results: list,
    ) -> "dict":
        """Invoke run_merger for a single repo. config provides all model fields."""
        ws_repo = next(
            (r for r in manifest.repos if r.repo_name == repo_name), None
        )
        if ws_repo is None or ws_repo.git_init_result is None:
            return {"success": False, "merged_branches": [], "failed_branches": []}

        git_init = ws_repo.git_init_result
        integration_branch = git_init.get("integration_branch", "")
        if not integration_branch:
            return {"success": False, "merged_branches": [], "failed_branches": []}

        branches_to_merge = [
            {
                "branch_name": r.branch_name,
                "issue_name": r.issue_name,
                "result_summary": r.result_summary,
                "files_changed": r.files_changed,
                "issue_description": issue_by_name.get(r.issue_name, {}).get("description", ""),
            }
            for r in issue_results
        ]

        # Uses config.merger_model and config.ai_provider (ExecutionConfig properties)
        result = await call_fn(
            f"{node_id}.run_merger",
            repo_path=ws_repo.absolute_path,
            integration_branch=integration_branch,
            branches_to_merge=branches_to_merge,
            file_conflicts=file_conflicts,
            prd_summary=dag_state.prd_summary,
            architecture_summary=dag_state.architecture_summary,
            artifacts_dir=dag_state.artifacts_dir,
            level=level_result.level_index,
            model=config.merger_model,
            ai_provider=config.ai_provider,
        )
        return result

    # Dispatch all repo merges concurrently
    tasks = [
        _call_merger_for_repo(repo_name, issues)
        for repo_name, issues in by_repo.items()
    ]
    repo_names = list(by_repo.keys())
    results = await asyncio.gather(*tasks, return_exceptions=True)

    last_good: dict | None = None
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            continue
        dag_state.merge_results.append({**result, "repo_name": repo_names[i]})
        for b in result.get("merged_branches", []):
            if b not in dag_state.merged_branches:
                dag_state.merged_branches.append(b)
        for b in result.get("failed_branches", []):
            if b not in dag_state.unmerged_branches:
                dag_state.unmerged_branches.append(b)
        if result.get("success"):
            last_good = result

    return last_good

9. Detailed Function Specifications

9.1 Module Dependency Graph

swe_af/execution/schemas.py
  imports: re, os, pydantic (BaseModel, ConfigDict, PrivateAttr, model_validator, field_validator)
  exports: RepoSpec, WorkspaceRepo, WorkspaceManifest, RepoPRResult,
           BuildConfig (extended), BuildResult (extended), DAGState (extended),
           CoderResult (extended), GitInitResult (extended), MergeResult (extended),
           IssueResult (extended), _derive_repo_name

swe_af/prompts/_utils.py
  imports: swe_af.execution.schemas (WorkspaceManifest)
  exports: workspace_context_block

swe_af/prompts/product_manager.py
  imports: swe_af.execution.schemas (WorkspaceManifest)
           swe_af.prompts._utils (workspace_context_block)
  adds: pm_task_prompt

swe_af/prompts/architect.py
  imports: swe_af.reasoners.schemas (PRD) [existing]
           swe_af.execution.schemas (WorkspaceManifest)
           swe_af.prompts._utils (workspace_context_block)
  adds: architect_task_prompt

swe_af/prompts/sprint_planner.py
  imports: swe_af.reasoners.schemas (Architecture, PRD) [existing]
           swe_af.execution.schemas (WorkspaceManifest)
           swe_af.prompts._utils (workspace_context_block)
  adds: sprint_planner_task_prompt

swe_af/prompts/coder.py
  imports: swe_af.execution.schemas (WorkspaceManifest)
           swe_af.prompts._utils (workspace_context_block)
  modifies: coder_task_prompt (adds workspace_manifest, target_repo params)

swe_af/prompts/verifier.py
  imports: swe_af.execution.schemas (WorkspaceManifest)
           swe_af.prompts._utils (workspace_context_block)
  modifies: verifier_task_prompt (adds workspace_manifest param)

swe_af/prompts/workspace.py
  imports: swe_af.execution.schemas (WorkspaceManifest)
  modifies: workspace_setup_task_prompt (adds workspace_manifest param)

swe_af/prompts/github_pr.py
  imports: swe_af.execution.schemas (RepoPRResult)
  modifies: github_pr_task_prompt (adds all_pr_results param)

swe_af/reasoners/schemas.py
  modifies: PlannedIssue (adds target_repo field)

swe_af/app.py
  imports: swe_af.execution.schemas (_derive_repo_name as _repo_name_from_url,
           RepoPRResult, WorkspaceManifest)
  adds: _clone_repos
  modifies: build, execute

swe_af/execution/dag_executor.py
  adds: _init_all_repos
  modifies: run_dag, _execute_level, _merge_level_branches

swe_af/execution/coding_loop.py
  modifies: run_coding_loop (propagates coder_result.repo_name to IssueResult)

9.2 Acceptance Criterion → Code Mapping

AC Code Location Key Assertion
AC-01 RepoSpec in schemas.py role validated, create_pr=True default, sparse_paths=[] default
AC-02 BuildConfig._normalize_repos repo_url synthesizes single RepoSpec(role='primary')
AC-03 BuildConfig._normalize_repos Two primaries → ValueError
AC-04 BuildConfig._normalize_repos repo_url + repos simultaneously → ValueError
AC-05 BuildConfig.repo_url backfill Primary's repo_url backfilled into self.repo_url
AC-06 WorkspaceManifest in schemas.py primary_repo_name, model_dump_json round-trip
AC-07 RepoPRResult in schemas.py repo_name, success, pr_url fields
AC-08 BuildResult.pr_url property First successful pr_url or ""
AC-09 DAGState.workspace_manifest Field exists, default None
AC-10 PlannedIssue.target_repo Field exists, default ""
AC-11 CoderResult.repo_name Field exists, default ""
AC-12 GitInitResult.repo_name Field exists, default ""
AC-13 MergeResult.repo_name Field exists, default ""
AC-14 workspace_context_block Single-repo → ""
AC-15 workspace_context_block Multi-repo → table with names and paths
AC-16 pm_task_prompt signature workspace_manifest parameter
AC-17 architect_task_prompt signature workspace_manifest parameter
AC-18 sprint_planner_task_prompt workspace_manifest param; multi-repo prompt contains 'target_repo'
AC-19 coder_task_prompt workspace_manifest, target_repo params; /tmp/lib in output
AC-20 verifier_task_prompt signature workspace_manifest parameter
AC-21 workspace_setup_task_prompt signature workspace_manifest parameter
AC-22 github_pr_task_prompt signature all_pr_results parameter
AC-23 _clone_repos in app.py async def, cfg + artifacts_dir params
AC-24 BuildConfig._normalize_repos Duplicate repo URLs → ValueError
AC-25 All existing tests No existing schemas renamed; defaults preserve behavior

10. Backward Compatibility

10.1 Single-Repo Callers

BuildConfig(repo_url="https://github.com/org/repo.git")
# → _normalize_repos synthesizes repos=[RepoSpec(repo_url=..., role='primary')]
# → cfg.repo_url unchanged, cfg.primary_repo returns the single RepoSpec
# → build() sees len(cfg.repos)==1, takes existing single-repo path
# → BuildResult.pr_url property returns the same string as before

10.2 BuildResult Serialization

result = BuildResult(..., pr_results=[RepoPRResult(..., pr_url="https://...")])
result.pr_url           # "https://..." (property)
result.model_dump()["pr_url"]  # "https://..." (injected by model_dump override)

10.3 Prompt Functions

All new parameters have default=None. Callers that don't pass them get identical
output to today.

10.4 Test Compatibility

Tests that construct BuildResult(pr_url='...') must change to use pr_results=[...].
All other existing test patterns are unaffected.


11. Data Flow Examples

11.1 Multi-Repo Build

Input:
  build(goal="...", config={
    "repos": [
      {"repo_url": "https://github.com/org/api.git", "role": "primary"},
      {"repo_url": "https://github.com/org/lib.git", "role": "dependency", "create_pr": False},
    ]
  })

Step 1 — BuildConfig validation:
  _normalize_repos: 1 primary ✓, no dups ✓
  cfg.repo_url = "https://github.com/org/api.git" (backfilled)
  cfg.primary_repo = RepoSpec(repo_url="...api.git", role="primary")

Step 2 — _clone_repos:
  asyncio.gather([
    asyncio.to_thread(git clone api.git /workspace/api),
    asyncio.to_thread(git clone lib.git /workspace/lib),
  ])
  → branches resolved via git rev-parse: api="main", lib="develop"
  → WorkspaceManifest(
      workspace_root="/workspace",
      repos=[
        WorkspaceRepo(repo_name="api", role="primary", branch="main",
                      absolute_path="/workspace/api", create_pr=True),
        WorkspaceRepo(repo_name="lib", role="dependency", branch="develop",
                      absolute_path="/workspace/lib", create_pr=False),
      ],
      primary_repo_name="api"
    )
  repo_path = "/workspace/api"

Step 3 — execute(workspace_manifest=manifest.model_dump()):
  dag_state.workspace_manifest = {workspace_root: ..., repos: [...]}
  _init_all_repos:
    asyncio.gather([
      run_git_init(repo_path="/workspace/api") → {integration_branch: "swe-af/api/abc123", ...}
      run_git_init(repo_path="/workspace/lib") → {integration_branch: "swe-af/lib/abc123", ...}
    ])
    manifest.repos[0].git_init_result = {integration_branch: "swe-af/api/abc123", ...}
    manifest.repos[1].git_init_result = {integration_branch: "swe-af/lib/abc123", ...}
    dag_state.workspace_manifest = manifest.model_dump()  # updated

Step 4 — Sprint planner prompt (multi-repo):
  sprint_planner_task_prompt(workspace_manifest=manifest, ...)
  → prompt contains "target_repo" mandate
  → PlannedIssue outputs include target_repo="api" or target_repo="lib"

Step 5 — Coder (per issue):
  coder_task_prompt(issue=..., workspace_manifest=manifest, target_repo="lib")
  → prompt starts with CRITICAL block: "Absolute path: /workspace/lib"

Step 6 — Merge (per level):
  level_result.completed has IssueResults with repo_name="api" or repo_name="lib"
  _merge_level_branches groups by repo_name:
    asyncio.gather([
      run_merger(repo_path="/workspace/api", integration_branch="swe-af/api/abc123", ...),
      run_merger(repo_path="/workspace/lib", integration_branch="swe-af/lib/abc123", ...),
    ])

Step 7 — PR creation:
  for ws_repo in manifest.repos:
    if ws_repo.create_pr:  # True for api, False for lib
      run_github_pr(repo_path="/workspace/api", ...)
  pr_results = [RepoPRResult(repo_name="api", pr_url="https://...api/pull/5", success=True)]
  # lib skipped (create_pr=False)

Step 8 — BuildResult:
  BuildResult(pr_results=pr_results)
  .pr_url → "https://...api/pull/5"  (first successful)
  .model_dump()["pr_url"] → "https://...api/pull/5"

11.2 Single-Repo Build (Identical to Today)

Input: build(goal="...", repo_url="https://github.com/org/repo.git")

BuildConfig._normalize_repos: repos=[RepoSpec(repo_url=..., role="primary")]
len(cfg.repos)==1 → single-repo path in build()
manifest = None
execute(workspace_manifest=None) → dag_state.workspace_manifest = None
_init_all_repos: early return (manifest is None)
_merge_level_branches: single-repo path (existing logic)
PR creation: existing logic → pr_url="https://..."
pr_results=[RepoPRResult(repo_name="repo", pr_url="https://...", success=True)]
BuildResult(pr_results=pr_results).pr_url == "https://..."  ✓

12. Architectural Decisions

Decision 1: _derive_repo_name in schemas.py (resolves C-NEW-2)

Chosen: Canonical function in schemas.py. Imported into app.py as alias.

Rejected: Two functions with identical logic. They will diverge across releases.

Consequence: schemas.py gains import re (not yet present). One re import is
the only addition to schemas.py's import block.

Decision 2: WorkspaceRepo.model_config = ConfigDict(frozen=False) (resolves B-NEW-4)

Chosen: Mutable model. _init_all_repos assigns ws_repo.git_init_result in-place.

Rejected: Rebuild manifest with updated repos list after asyncio.gather. More
complex, requires re-constructing WorkspaceManifest, and the concurrent gather pattern
makes in-place mutation the natural fit.

Consequence: WorkspaceRepo instances can be mutated post-construction.
Implementers should not pass them to functions that depend on immutability.

Decision 3: workspace_manifest as dict | None in DAGState (resolves C-NEW-4)

Chosen: Store as WorkspaceManifest.model_dump(). Survives JSON checkpoint
serialization. Consumers reconstruct via WorkspaceManifest(**dag_state.workspace_manifest).

Rejected: WorkspaceManifest | None field. Would break checkpoint JSON serialization
without custom encoders.

Consequence: Each consumer adds one reconstruction call. All seven checkpoint
round-trips (one per level) work out of the box.

Decision 4: Branch via git rev-parse after clone (resolves C-NEW-1)

Chosen: Run git -C {dest} rev-parse --abbrev-ref HEAD in a thread after clone.

Rejected: spec.branch or 'main' fallback. 'main' would be wrong for repos with
'master', 'develop', or other default branches.

Consequence: One extra subprocess per cloned repo. Cost is negligible (~5ms) vs.
clone time (seconds to minutes).

Decision 5: IssueResult.repo_name from CoderResult (resolves B-NEW-1)

Chosen: coding_loop.py propagates coder_result.repo_name to IssueResult.repo_name.
dag_executor.py backfills from issue['target_repo'] when CoderResult.repo_name is empty.

Rejected: Have _merge_level_branches look up repo_name from the issue dict by name.
Issue dict lookup is fragile when names diverge from their dict representations.

Consequence: Two propagation points (coding_loop and dag_executor). The backfill
in dag_executor is the safety net when an exception-wrapped IssueResult is created
without going through run_coding_loop.

Decision 6: One merger call per repo, concurrent (Architectural Decision 8)

Chosen: asyncio.gather over per-repo run_merger calls in _merge_level_branches.

Rejected: Single merger call handling all repos. Merger agent prompt assumes one
integration branch; extending it for multiple branches would require prompt redesign.

Consequence: N concurrent merger calls per level (N = repos with completed issues).
Total wall-clock time is bounded by the slowest merger, not sum of all mergers.

Decision 7: No new files except swe_af/prompts/_utils.py (resolves parallel implementation safety)

Chosen: All Pydantic types in schemas.py, all prompt changes in existing files,
one new utility file _utils.py.

Rejected: New module swe_af/execution/multi_repo.py. Extra file means extra import
changes and more surface area for merge conflicts between parallel implementation agents.

Consequence: schemas.py grows ~150 lines. app.py grows ~100 lines. Each prompt
file grows ~10–30 lines. All growth is additive; no existing code is restructured.

Decision 8: execute() gains workspace_manifest parameter (resolves C-NEW-4)

Chosen: execute() reasoner gains workspace_manifest: dict | None = None. build()
passes manifest.model_dump(). Inside execute(), run_dag() receives it and assigns
dag_state.workspace_manifest = workspace_manifest immediately after _init_dag_state().

Rejected: Pack manifest into git_config dict. git_config has a defined shape;
appending unrelated data to it would be surprising coupling.

Consequence: execute() and run_dag() both gain one optional parameter with
None default. All existing callers are unaffected.

Decision 9: BuildResult.pr_url as property with model_dump injection

Chosen: pr_url is a @property. model_dump is overridden to inject it.

Rejected: Keep pr_url: str field alongside pr_results. Two sources of truth for
the same value will diverge whenever pr_results is updated without updating pr_url.

Consequence: BuildResult(pr_url='...') construction raises ValidationError.
Known construction sites must be updated (listed in §4.2).


13. Extension Points

The following capabilities are out of scope per the PRD but the architecture
accommodates them without schema changes:

  • Per-repo credentials: RepoSpec would gain git_credentials_key: str = "".
    _clone_single injects env vars from a vault. No other changes.
  • Sparse checkout: RepoSpec.sparse_paths is already defined. _clone_single
    would add git sparse-checkout set after clone.
  • Automatic dependency detection: A pre-flight step reads pyproject.toml/
    Cargo.toml to populate cfg.repos. BuildConfig.repos is the stable intake.
  • Cross-repo atomic releases: Post-PR orchestration uses pr_results to coordinate
    tagging. list[RepoPRResult] provides all PR URLs in one place.
  • Git submodules: Explicitly out of scope per PRD. _clone_single would add
    --recurse-submodules flag if needed.

Architecture Revision 3 — addresses B-NEW-1, B-NEW-2, B-NEW-3, B-NEW-4,
C-NEW-1, C-NEW-2, C-NEW-3, C-NEW-4, C-NEW-5 and all minor notes from tech lead review.

- Add pytest-asyncio to dev deps with asyncio_mode='auto' in pyproject.toml
- Add root tests/conftest.py with mock_agent_ai and agentfield_server_guard fixtures
- Add functional tests: test_planner_pipeline.py, test_planner_execute.py
- Add error-path tests: test_malformed_responses.py
- Add isolation tests: test_node_id_isolation.py (16 tests, subprocess-based)
- Fix existing fast/ tests: router wiring, verifier, cross-feature, integration tests
- All 434 tests pass (1 skipped) with zero real API calls

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@AbirAbbas AbirAbbas marked this pull request as ready for review February 18, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant